From 11009c513462ac312431fcc1c483c23d4adc6e73 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:31:15 -0700 Subject: [PATCH 001/269] fix(native-chat): a child's root exit is reported even during its close, and bookkeeping after it never reads as unproven - Both connections report the root process's exit once, with `expected` set when a close had begun. A close that came back unproven and whose root exits later is finished by the adapter, and its end reaches the host like any other. - A Claude close whose resume-point write fails after the exit was proven, and a Codex close whose terminal row is refused, now end the session and report the failure, instead of keeping a dead child indexed as if its exit were unproven. - A Codex close whose forced tree kill can't prove the descendants gone but saw the root exit reports the descendants and counts the root exit. - Every child exit with an identity, expected or not, is forwarded to the host. --- ...laude-stream-json-connection-close.test.ts | 33 ++++++++++++++++ .../claude/claude-stream-json-connection.ts | 8 ++-- .../claude-structured-requested-stop.test.ts | 3 +- .../claude-structured-session-acquisition.ts | 18 ++++----- .../claude-structured-session-adapter.ts | 14 ++++++- .../claude-structured-session-close.test.ts | 11 ++++-- .../claude/claude-structured-session-close.ts | 32 +++++++-------- ...claude-structured-session-recovery.test.ts | 16 +++++--- .../claude/claude-structured-session-state.ts | 2 + .../codex-app-server-connection-types.ts | 4 +- .../codex/codex-app-server-connection.test.ts | 9 +++-- src/main/codex/codex-app-server-connection.ts | 18 ++++++--- .../codex-requested-close-turn-timing.test.ts | 39 +++---------------- .../codex/codex-structured-session-acquire.ts | 4 +- .../codex-structured-session-close.test.ts | 8 ++-- .../codex/codex-structured-session-close.ts | 23 +++-------- .../codex-structured-session-teardown.ts | 3 +- ...tured-agent-session-provider-exit-proof.ts | 16 ++++++-- .../structured-agent-session-runtime.ts | 3 +- .../structured-claude-runtime-adapter.ts | 3 +- 20 files changed, 153 insertions(+), 114 deletions(-) diff --git a/src/main/claude/claude-stream-json-connection-close.test.ts b/src/main/claude/claude-stream-json-connection-close.test.ts index 22076f3600ea..f5bbc35d2698 100644 --- a/src/main/claude/claude-stream-json-connection-close.test.ts +++ b/src/main/claude/claude-stream-json-connection-close.test.ts @@ -162,6 +162,39 @@ describe('Claude stream-json close ordering', () => { expect(next).toHaveBeenCalledOnce() }) + it('reports a root that exits after its close came back unproven as that close ending', async () => { + mocks.refresh.mockReset() + mocks.proveClaudeChildExit.mockReset() + mocks.refresh.mockResolvedValue(undefined) + mocks.proveClaudeChildExit.mockResolvedValue(false) + const child = fakeChild() + const next = vi.fn(() => new Promise>>(() => {})) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection. + const queryImpl = ((params: Parameters[0]) => { + params.options?.spawnClaudeCodeProcess?.({ + command: 'claude', + args: [], + env: {}, + signal: new AbortController().signal + }) + return { + [Symbol.asyncIterator]: () => ({ next }) + } + }) as unknown as typeof query + const exits: (boolean | undefined)[] = [] + const connection = await openClaudeStreamJsonConnection( + { pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' }, + { onExit: (_error, exit) => exits.push(exit?.expected) }, + () => child, + queryImpl + ) + await expect(connection.close()).resolves.toBe(false) + + child.emit('exit', 0, 'SIGTERM') + + expect(exits).toEqual([true]) + }) + it('waits for the live tree refresh before ending stdin', async () => { const refreshDone = Promise.withResolvers() mocks.refresh.mockReturnValueOnce(refreshDone.promise) diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index 6f67e04b36ea..b7c50646fb46 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -65,7 +65,9 @@ export type ClaudeStreamJsonConnectionHandlers = { onUserDialog?: OnUserDialog /** A transport/process fault that is not itself first-hand root exit proof. */ onFault?: (error: Error) => void - onExit?: (error: Error) => void + /** The root process exited, reported once. `expected`: a close had begun, so it is that close's + * end, even one that ran out of its own escalation first and came back unproven. */ + onExit?: (error: Error, exit?: { expected: boolean }) => void } /** @@ -222,9 +224,9 @@ export async function openClaudeStreamJsonConnection( faultReported = true handlers.onFault?.(terminalError) } - if (!closing && exited && !exitReported) { + if (exited && !exitReported) { exitReported = true - handlers.onExit?.(terminalError) + handlers.onExit?.(terminalError, { expected: closing }) } } diff --git a/src/main/claude/claude-structured-requested-stop.test.ts b/src/main/claude/claude-structured-requested-stop.test.ts index 993109f4a378..edcaff847bb8 100644 --- a/src/main/claude/claude-structured-requested-stop.test.ts +++ b/src/main/claude/claude-structured-requested-stop.test.ts @@ -85,6 +85,7 @@ describe('a requested stop of a structured Claude chat', () => { expect(turns.at(-1)).toMatchObject({ turnId: 'turn-1', state: 'interrupted' }) const ended = events.filter((event) => event.type === 'ended') expect(ended).toEqual([expect.objectContaining({ reason: 'claude session closed' })]) - expect(ended[0]).not.toHaveProperty('cause') + // The host ends the child's record on it, as the close Orca asked for, never as a crash. + expect(ended[0]).toMatchObject({ cause: 'requested-close' }) }) }) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 404787620e95..39d2e0cdbe2f 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -153,12 +153,9 @@ export async function acquireClaudeSession({ settle() } } - const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({ - sessionId, - prompts, - emit: (event) => - callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, event)) - }) + const emit = (event: Parameters[2]): void => + callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, event)) + const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({ sessionId, prompts, emit }) try { const launch = await resolveClaudeAcquisitionLaunch({ @@ -200,7 +197,12 @@ export async function acquireClaudeSession({ childEnded ??= error initProof.reject(error) }, - onExit: (error) => { + onExit: (error, exit) => { + if (exit?.expected) { + // The end of a close Orca began; that close settles it, or finishes it now. + callbacks.finishClose(sessionId, attempt) + return + } // The child exited on its own; marked in place, as the fault report may hold this error. withObservedProviderExit(error) childEnded ??= error @@ -218,8 +220,6 @@ export async function acquireClaudeSession({ deps.now ? { now: deps.now } : {} ) acquisitions.assertCurrent(sessionId, attempt) - const emit = (event: Parameters[2]): void => - callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, event)) if (connection.pid === undefined) { // A pid-less spawn always reports its error next; surface that, not the missing pid. await initProof.promise diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 746d603308f1..a752de5165d9 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -26,7 +26,11 @@ import { type ClaudeStructuredSessionAdapterDeps, type ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import { closeAllClaudeSessions, closeClaudeSession } from './claude-structured-session-close' +import { + closeAllClaudeSessions, + closeClaudePublishedSessionForDeps, + closeClaudeSession +} from './claude-structured-session-close' import { claudeStoppedRequestEndWait } from './claude-request-end-wait' import { drainClaudeObservedExits, @@ -95,6 +99,14 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda emit: (session, _events, event) => this.emit(session, event), handleExit: (sessionId, attempt, error) => observeClaudeSessionExit(this.exitLifecycle, sessionId, attempt, error), + finishClose: (sessionId, attempt) => { + if (this.sessions.get(sessionId)?.connection === attempt.connection) { + // Joins a close still running; finalizes one that came back unproven before the exit. + void closeClaudePublishedSessionForDeps(this.sessions, sessionId, this.deps).catch( + () => undefined + ) + } + }, settleExit: (sessionId, exit) => settleClaudeUnexpectedExit(this.exitLifecycle, sessionId, exit) } diff --git a/src/main/claude/claude-structured-session-close.test.ts b/src/main/claude/claude-structured-session-close.test.ts index 8881c6ae74b8..0e67b22ade4a 100644 --- a/src/main/claude/claude-structured-session-close.test.ts +++ b/src/main/claude/claude-structured-session-close.test.ts @@ -126,7 +126,11 @@ describe('Claude published session close lifecycle', () => { ).sessions.get('session-1') const disposeTranslator = vi.spyOn(session!.translator!, 'dispose') - await expect(adapter.closeSession('session-1')).rejects.toBe(persistenceError) + // The exit is proven; the failed cursor write is reported, never read as an unproven exit. + await expect(adapter.closeSession('session-1')).rejects.toMatchObject({ + name: 'AgentSessionAcquisitionExitProvenError', + cause: persistenceError + }) // The child is provably dead; a failed cursor write may not suppress the end. expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) expect(events.filter((event) => event.type === 'handle')).toHaveLength(0) @@ -134,10 +138,9 @@ describe('Claude published session close lifecycle', () => { // A close Orca asked for stops the child still running, then the host hears the session end. expect(childWork).toEqual(['live', 'ended', 'session-ended']) + // Nothing of the dead child stays indexed for a retry. await expect(adapter.closeSession('session-1')).resolves.toBe(true) - expect(persistHandle).toHaveBeenCalledTimes(2) - // The retry persists the same cursor without a second lifecycle end. - expect(events.filter((event) => event.type === 'handle')).toHaveLength(1) + expect(persistHandle).toHaveBeenCalledOnce() expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) expect(disposeTranslator).toHaveBeenCalledOnce() }) diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 68f3dbbae1fe..05d767054094 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -7,6 +7,7 @@ import type { } from './claude-structured-session-state' import { cancelClaudeAcquisitionAttempt } from './claude-structured-session-state' import { + AgentSessionAcquisitionExitProvenError, AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRootExitObservedError, AgentSessionPreSpawnError @@ -137,6 +138,10 @@ async function finalizeClaudePublishedSession( type: 'ended', sessionId: input.sessionId, reason: 'claude session closed', + // The host ends the child's record on it, whoever was still waiting on the close. + cause: 'requested-close', + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration, observedAt: Date.now() } as const let callbackError: unknown @@ -152,8 +157,6 @@ async function finalizeClaudePublishedSession( let persistenceError: unknown try { await persistence - session.closeFinalized = true - input.sessions.delete(input.sessionId) deliver({ type: 'handle', sessionId: input.sessionId, @@ -162,16 +165,13 @@ async function finalizeClaudePublishedSession( fence: session.fence }) } catch (error) { - // Keep the closed session indexed so a retry can persist the same cursor. - // Removing it first would turn a durable-write failure into a no-op retry. - if (session.closePersistence === persistence) { - session.closePersistence = undefined - } persistenceError = error } - // The connection already proved the child dead, so the session has ended - // whatever the durable write did: withholding it would strand the renderer on - // a session nothing re-drives. Emitted once, so a retry only re-persists. + // The connection already proved the child dead, so the session has ended whatever the resume + // point's write did: that write is bookkeeping, reported below, never a reason to keep a dead + // child indexed as if its exit were unproven. + session.closeFinalized = true + input.sessions.delete(input.sessionId) if (!session.closeEnded) { session.closeEnded = true try { @@ -186,15 +186,15 @@ async function finalizeClaudePublishedSession( session.translator?.dispose() } } - if (persistenceError) { - throw persistenceError - } - if (callbackThrew) { - throw callbackError - } if (rootExitVerdict) { throw rootExitVerdict } + if (persistenceError !== undefined || callbackThrew) { + // The exit is proven; only what followed it failed, which the caller reports. + throw new AgentSessionAcquisitionExitProvenError( + persistenceError !== undefined ? persistenceError : callbackError + ) + } return true } diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index 71a7ac9ce319..7005f93f5837 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -95,13 +95,16 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { ).sessions.get('session-1') const disposeTranslator = vi.spyOn(session!.translator!, 'dispose') - await expect(adapter.closeSession('session-1')).rejects.toBe(callbackError) + await expect(adapter.closeSession('session-1')).rejects.toMatchObject({ + name: 'AgentSessionAcquisitionExitProvenError', + cause: callbackError + }) expect(events.filter((event) => event.type === 'handle')).toHaveLength(1) expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) expect(disposeTranslator).toHaveBeenCalledOnce() }) - it('retains a closed session until its durable cursor persistence succeeds', async () => { + it('does not keep a dead child indexed over a failed resume-point write', async () => { const claude = fakeClaude() const persistenceError = new Error('store unavailable') const persistHandle = vi @@ -111,10 +114,13 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { const adapter = adapterFor(claude, {}, [], [], undefined, persistHandle) await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - await expect(adapter.closeSession('session-1')).rejects.toBe(persistenceError) - expect(persistHandle).toHaveBeenCalledTimes(1) + // The exit is proven, so the close reports the write and still ends the session. + await expect(adapter.closeSession('session-1')).rejects.toMatchObject({ + name: 'AgentSessionAcquisitionExitProvenError', + cause: persistenceError + }) await expect(adapter.closeSession('session-1')).resolves.toBe(true) - expect(persistHandle).toHaveBeenCalledTimes(2) + expect(persistHandle).toHaveBeenCalledOnce() }) it('persists the last completed turn message before graceful close', async () => { diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index d61df04ac75c..670f28e5531b 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -345,5 +345,7 @@ export type ClaudeAcquireCallbacks = { event: ClaudeStructuredSessionEvent ) => void handleExit: (sessionId: string, attempt: ClaudeAcquisitionAttempt, error: Error) => void + /** The root exited during a close Orca began: finishes that close for this exact child. */ + finishClose: (sessionId: string, attempt: ClaudeAcquisitionAttempt) => void settleExit: (sessionId: string, exit: ClaudeSessionExit) => Promise } diff --git a/src/main/codex/codex-app-server-connection-types.ts b/src/main/codex/codex-app-server-connection-types.ts index df3f40ac39a5..8c6d4c13d9dd 100644 --- a/src/main/codex/codex-app-server-connection-types.ts +++ b/src/main/codex/codex-app-server-connection-types.ts @@ -8,7 +8,9 @@ export type CodexAppServerConnectionHandlers = { onNotification?: (method: string, params: unknown) => void onServerRequest?: (request: CodexAppServerServerRequest) => void onUnhandledFrame?: (kind: string, payload: unknown) => void - onExit?: (error: Error) => void + /** The root process exited, reported once. `expected`: a close had begun, so it is that close's + * end, even one that gave up first and came back unproven. */ + onExit?: (error: Error, exit?: { expected: boolean }) => void /** Awaited once the child has a pid and before the handshake; a rejection reaps the child. */ onSpawned?: (pid: number) => Promise } diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index 3d95e7ce380f..962270423e33 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -812,13 +812,13 @@ describe('openCodexAppServerConnection', () => { await connection.close() }) - it('keeps a graceful close quiet when stdin breaks during the reap', async () => { + it('reports a graceful close as expected when stdin breaks during the reap', async () => { const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) answerInitialize(child) - const exits: string[] = [] + const exits: (boolean | undefined)[] = [] const connection = await openCodexAppServerConnection( { command: 'codex', args: ['app-server'] }, - { onExit: (error) => exits.push(error.message) }, + { onExit: (_error, exit) => exits.push(exit?.expected) }, spawnImpl ) child.stdin.on('finish', () => child.stdin.emit('error', new Error('write EPIPE'))) @@ -831,6 +831,7 @@ describe('openCodexAppServerConnection', () => { await connection.close() expect((await inFlight).message).toContain('EPIPE') - expect(exits).toHaveLength(0) + // The root's exit is the close's own end, never an unexpected death. + expect(exits).toEqual([true]) }) }) diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 3e7d16ea1bb0..e68761040351 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -126,9 +126,9 @@ export async function openCodexAppServerConnection( // Transport/protocol failures make the connection unusable immediately so // callers do not hang, but recovery must not treat that as a child exit // until the execution host has observed `exit`/`close`. - if (exitObserved && !closing && !exitReported) { + if (exitObserved && !exitReported) { exitReported = true - handlers.onExit?.(terminalError) + handlers.onExit?.(terminalError, { expected: closing }) } } @@ -257,11 +257,17 @@ export async function openCodexAppServerConnection( ) if (!exited) { const treeExited = await terminateProcessTree() - if (!treeExited) { - dispatcher.failPending(new Error('codex app-server process-tree exit was not proven')) - return false - } await waitForProcessExitUntil(exitPromise, FORCED_EXIT_MS) + if (!treeExited && exitObserved) { + // The lease follows the root, which is gone: a child left behind is reported, and + // blocks nothing. + console.warn( + '[codex-app-server] root exited but its process tree was not proven gone', + { + pid: child.pid + } + ) + } } } dispatcher.failPending(new Error('codex app-server connection closed')) diff --git a/src/main/codex/codex-requested-close-turn-timing.test.ts b/src/main/codex/codex-requested-close-turn-timing.test.ts index a7baf6a14b59..45754f9f8042 100644 --- a/src/main/codex/codex-requested-close-turn-timing.test.ts +++ b/src/main/codex/codex-requested-close-turn-timing.test.ts @@ -1,7 +1,6 @@ import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import { createCodexJournalTranslator } from './codex-structured-journal-translation' @@ -12,28 +11,19 @@ import type { CodexSession } from './codex-structured-session-state' afterEach(() => vi.useRealTimers()) describe('requested-close durable turn timing', () => { + // The exit is observed, so a refused terminal row is the host's to settle, never a reason to + // keep the dead child indexed for a retry. it.each([true, false])( - 'keeps the first exit receipt when retry requestedClose=%s', + 'ends the session with its exit receipt though its terminal row is refused (requestedClose=%s)', async (requestedClose) => { vi.useFakeTimers() vi.setSystemTime(1_000) - const terminalBodies: AgentJournalItemBody[] = [] - let refuseSettlement = true + // Backpressure refuses the terminal row the close's end would write. const sink: StructuredAgentSessionEventSink = { appendItem: () => {}, appendTombstone: () => {}, publish: () => {}, - tryAppendLifecycleBatch: (_id, mutations) => { - if (refuseSettlement) { - return { accepted: false, reason: 'backpressure' } - } - for (const mutation of mutations) { - if (mutation.kind === 'item') { - terminalBodies.push(mutation.body) - } - } - return { accepted: true } - } + tryAppendLifecycleBatch: () => ({ accepted: false, reason: 'backpressure' }) } const translator = createCodexJournalTranslator({ sink, @@ -80,22 +70,11 @@ describe('requested-close durable turn timing', () => { const onEvent = vi.fn() vi.setSystemTime(2_000) - await expect(closeCodexPublishedSession(sessions, 'session-1')).resolves.toBe(false) - expect(sessions.get('session-1')).toBe(session) - expect(session.ended).toBe(false) - - refuseSettlement = false - vi.setSystemTime(60_000) - await expect( - closeCodexPublishedSession(sessions, 'session-1', onEvent, { - expectedAcquisitionGeneration: 'replacement-generation' - }) - ).resolves.toBe(false) - expect(onEvent).not.toHaveBeenCalled() await expect( closeCodexPublishedSession(sessions, 'session-1', onEvent, { requestedClose }) ).resolves.toBe(true) expect(sessions.has('session-1')).toBe(false) + expect(session.ended).toBe(true) expect(onEvent).toHaveBeenCalledWith( expect.objectContaining({ cause: requestedClose ? 'requested-close' : 'unexpected-exit', @@ -103,12 +82,6 @@ describe('requested-close durable turn timing', () => { observedAt: 2_000 }) ) - expect(terminalBodies.find((body) => body.kind === 'turn')).toMatchObject({ - kind: 'turn', - state: 'interrupted', - startedAt: 1_000, - completedAt: 2_000 - }) } ) }) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 37c34a4bf3a1..bf40afa199bd 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -163,13 +163,15 @@ export async function acquireCodexStructuredSession(input: { Buffer.byteLength(JSON.stringify(payload ?? null), 'utf8') ), onSpawned: spawnIdentity.onSpawned, - onExit: (error) => { + onExit: (error, exit) => { try { handleCodexSessionExit({ sessions, sessionId, connection: acquisition.connection, error, + // The end of a close Orca began, even one that came back unproven before it. + ...(exit?.expected ? { closedByOrca: true as const } : {}), prompts: acquisition.prompts, ...(deps.onEvent ? { onEvent: deps.onEvent } : {}) }) diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index 9d80a0fa9829..73004e43c344 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -135,7 +135,7 @@ describe('Codex structured session close lifecycle', () => { expect(translator.handle).toHaveBeenCalledOnce() }) - it("ends a Stop's wait for its turn to open when a requested close cannot publish its end yet", async () => { + it("ends the session and a Stop's wait for its turn to open when a requested close cannot publish its end", async () => { const { connection, prompts, session, sessions } = backpressuredSession(true) let released = false void session.turnOpenWaits.wait('turn-1', 60_000).then(() => { @@ -151,10 +151,10 @@ describe('Codex structured session close lifecycle', () => { closedByOrca: true, prompts }) - ).toBe(false) + ).toBe(true) await Promise.resolve() - // Left for the retry, but the child is gone: nothing waits on a turn it would open. - expect(session.ended).toBe(false) + // The exit is observed: the refused row is the host's to settle, and nothing waits on a turn. + expect(session.ended).toBe(true) expect(released).toBe(true) }) diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index fe6c0d676735..0afd70a70e8c 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -14,11 +14,9 @@ export function handleCodexSessionExit(input: { sessionId: string connection: CodexAppServerConnection | null error: Error - /** Set by Orca's own close. Absent only from the connection's onExit, which the connection - * withholds while Orca is closing the child. */ + /** Set for the end of a close Orca began: by that close, or by the root's own exit report. */ closedByOrca?: true prompts?: CodexSession['prompts'] - allowFailedSettlement?: boolean onEvent?: (event: CodexStructuredSessionEvent) => void }): boolean { const session = input.sessions.get(input.sessionId) @@ -43,13 +41,9 @@ export function handleCodexSessionExit(input: { observedAt: session.exitObservedAt } as const // A synchronous sink rejection (usually backpressure) leaves the terminal rows to the host's - // exit settlement, which writes its own bounded fallback. - const admission = session.translator?.handle(event) ?? { accepted: true } - // The connection invokes onExit exactly once, so an unexpected exit is forwarded even when - // admission is backpressured; waiting for a second callback would strand the lease. - if (!admission.accepted && event.cause !== 'unexpected-exit' && !input.allowFailedSettlement) { - return false - } + // exit settlement, which writes its own bounded fallback. The exit itself is observed, so the + // session ends either way: holding a dead child as unproven over a refused row would strand it. + session.translator?.handle(event) session.ended = true // Nothing can echo for this child any more; the journal's pending-submission // recovery is what settles the sends these were armed for. @@ -69,7 +63,6 @@ export async function closeCodexPublishedSession( sessionId: string, onEvent?: (event: CodexStructuredSessionEvent) => void, options?: { - allowFailedSettlement?: boolean requestedClose?: boolean expectedFence?: number expectedAcquisitionGeneration?: string @@ -97,21 +90,15 @@ export async function closeCodexPublishedSession( return false } if (!session.ended) { - const handled = handleCodexSessionExit({ + handleCodexSessionExit({ sessions, sessionId, connection: session.connection, error: options?.unexpectedReason ?? new Error('codex session closed'), closedByOrca: true, prompts: session.prompts, - ...(options?.allowFailedSettlement ? { allowFailedSettlement: true } : {}), ...(onEvent ? { onEvent } : {}) }) - // Keep the closed session indexed when terminal settlement admission was - // rejected; a later close attempt retries the same stable lifecycle event. - if (!handled) { - return false - } } sessions.delete(sessionId) return true diff --git a/src/main/codex/codex-structured-session-teardown.ts b/src/main/codex/codex-structured-session-teardown.ts index 80b06ab1c36a..115e794d8310 100644 --- a/src/main/codex/codex-structured-session-teardown.ts +++ b/src/main/codex/codex-structured-session-teardown.ts @@ -40,7 +40,7 @@ export class CodexStructuredSessionTeardown { this.deps.sessions, sessionId, this.deps.onEvent, - { allowFailedSettlement: true, requestedClose: false } + { requestedClose: false } ) return this.settled(sessionId, closed) } @@ -63,7 +63,6 @@ export class CodexStructuredSessionTeardown { return Promise.resolve(false) } return closeCodexPublishedSession(this.deps.sessions, sessionId, this.deps.onEvent, { - allowFailedSettlement: true, requestedClose: false, expectedFence: fence, expectedAcquisitionGeneration: acquisitionGeneration, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-exit-proof.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-exit-proof.ts index 9d27e570db4a..1545a8fa618d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-exit-proof.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-exit-proof.ts @@ -50,16 +50,24 @@ function provenExitAcquisitionFailure(cause: unknown): unknown { } /** Whether a stop left the provider root gone. The lease follows the root, so a first-hand root - * exit or a processless child ends the session whatever its descendants did; any other - * failure still throws. */ -export async function stopAgentSessionProviderRoot(stop: () => Promise): Promise { + * exit or a processless child ends the session whatever its descendants did. A descendant left + * unconfirmed, or bookkeeping that failed after the proven exit, is only `report`ed. Any other + * failure throws. */ +export async function stopAgentSessionProviderRoot( + stop: () => Promise, + report?: (error: Error) => void +): Promise { try { return (await stop()) === true } catch (error) { if ( error instanceof AgentSessionAcquisitionRootExitObservedError || - isAgentSessionPreSpawnError(error) + error instanceof AgentSessionAcquisitionExitProvenError ) { + report?.(error) + return true + } + if (isAgentSessionPreSpawnError(error)) { return true } throw error diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 3785fdeaf5ce..4c621a5e0c3b 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -271,7 +271,8 @@ async function installOnJournal( onDispatchSettledLate, onPrimaryThreadStoppedRunning: releaseUnansweredDispatches, onEvent: (event) => { - if (event.type === 'ended' && 'cause' in event && event.cause === 'unexpected-exit') { + // Every exit, expected or not: the host ends that child's record. + if (event.type === 'ended' && 'cause' in event) { lifecycle.deliver(event) } } diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 9024c65b503d..246f5b5eea3f 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -47,9 +47,10 @@ export function structuredClaudeLifecycleEvent( if (event.type === 'started') { return event } + // Every exit of a child with an identity, expected or not: the host ends that child's record. if ( event.type === 'ended' && - event.cause === 'unexpected-exit' && + event.cause !== undefined && event.fence !== undefined && event.acquisitionGeneration ) { From b9667b52ea6e5b490b59c967aa03d88d17e846c5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:31:19 -0700 Subject: [PATCH 002/269] fix(native-chat): the exit ends the child's record; an unfinished stop is the child's own close, which everyone joins - The host keeps no stored "stop still owed" record any more. A stop begins the child's close (`child.close`), which lives on the child and ends with it. A second Stop, the idle reaper, quit, a send and an option/answer/goal/rewind all join that close instead of retrying a separate obligation. - A caller waits on the close only as long as the step deadline; the close itself is never abandoned. A proof that lands after every caller stopped waiting reaches the host as the adapter's report of that exit, which ends the record through the same handler. - Once the exit is proven, draining, settling, the lease release and the adapter's acknowledgement are each attempted and reported on failure; none keeps the child on record. A start, and the handle's close, write a release that failed from this host's proof of that exit, so a failed write never refuses a send. - A start that meets a close still unverifiable is refused with `previousExitUnverifiable`, so the queued message is rejected with a send-again reason; nothing is held and nothing starts beside the old process. - The idle sweep goes back to idle reaping only. - Removes #24333's retry entry points, the wait row and its hold rule, the ask/failure cursors on the stored record, and the stop's own wake. Tests replace the #24333 unproven-stop test: a send joining an unproven close and an in-flight one, a late proof past the caller's bound, a root exiting after its close gave up, a proven exit whose resume-point write and lease release both failed, an unverifiable close rejecting the send and refusing an option change, a surviving descendant, quit and the idle reaper; and Codex's unverifiable, late-exit and joined-close cases. --- ...-agent-session-accept-then-deliver.test.ts | 10 +- .../structured-agent-session-agent-start.ts | 60 +- .../structured-agent-session-chat-stop.ts | 15 + .../structured-agent-session-child-close.ts | 263 ++++++++ ...red-agent-session-claude-root-exit.test.ts | 3 +- ...ssion-claude-stop-exit-ends-record.test.ts | 470 +++++++++++++++ ...-session-claude-unproven-stop-send.test.ts | 569 ------------------ ...ctured-agent-session-close-verdict.test.ts | 15 +- ...tured-agent-session-codex-stop-row.test.ts | 79 ++- ...d-agent-session-conversation-close.test.ts | 65 +- ...red-agent-session-conversation-lifetime.ts | 14 +- ...ed-agent-session-conversation-stop.test.ts | 3 +- .../structured-agent-session-delivery-loop.ts | 22 +- ...structured-agent-session-event-recovery.ts | 18 +- ...uctured-agent-session-eviction-deadline.ts | 13 +- .../structured-agent-session-eviction.test.ts | 154 +---- .../structured-agent-session-eviction.ts | 113 ++-- .../structured-agent-session-host-lifetime.ts | 232 ++----- ...tructured-agent-session-host-stop-event.ts | 21 +- .../structured-agent-session-host-types.ts | 23 +- .../structured-agent-session-host.ts | 13 +- ...tructured-agent-session-idle-sweep.test.ts | 1 - .../structured-agent-session-idle-sweep.ts | 9 - ...ructured-agent-session-mutation-context.ts | 6 +- ...structured-agent-session-provider-child.ts | 18 +- ...ructured-agent-session-send-preparation.ts | 7 +- ...d-agent-session-stop-event-entries.test.ts | 13 +- ...red-agent-session-surface-lifetime.test.ts | 30 +- .../structured-agent-session-turns-cancel.ts | 6 +- ...tructured-agent-session-unexpected-exit.ts | 3 + ...ctured-agent-session-wind-down-wait-row.ts | 83 --- 31 files changed, 1032 insertions(+), 1319 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index b3f91aea7002..1b4a4e95b8c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -773,9 +773,10 @@ describe('an eviction between acceptance and handover', () => { }) }) -// A close abandons what is queued before it stops the child, so a release that then fails still -// leaves every queued message rejected as closed, never blamed on the provider. -describe('a close that stops the child and then fails', () => { +// A close abandons what is queued before it stops the child, so a wind-down step that then fails +// (reported, never the close's failure) still leaves every queued message rejected as closed, +// never blamed on the provider. +describe('a close that stops the child and then a wind-down step fails', () => { const END_CHILD = { evict: () => host.close(SESSION, 'evict') } satisfies Partial Promise>> @@ -803,8 +804,7 @@ describe('a close that stops the child and then fails', () => { const id = await accept('hello') await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) - await expect(END_CHILD[end]()).rejects.toThrow() - expect(host.hasSession(SESSION)).toBe(true) + await expect(END_CHILD[end]()).resolves.toBeUndefined() started.resolve() await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index d2ef237aa1b6..ec1289d401dd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -29,9 +29,10 @@ import { attachStructuredAgentSessionUnderSerialize } from './structured-agent-s import { failedCreateRefusal } from './structured-agent-session-failed-create-refusal' import { adapterSupportsRecord } from './structured-agent-session-provider-support' import { - finishOwedStructuredAgentSessionWindDownUnderSerialize, - type StructuredAgentSessionLifetimeContext -} from './structured-agent-session-host-lifetime' + joinClosingStructuredAgentSessionChild, + releaseLeaseOfEndedStructuredAgentSessionChild +} from './structured-agent-session-child-close' +export { joinClosingStructuredAgentSessionChild } import { structuredAgentSessionResumeOperationId, structuredAgentSessionResumeParams @@ -52,48 +53,6 @@ export type StructuredAgentSessionResumeOutcome = /** The attach's caller key: the ledger row a start settles is Orca's own. */ const AGENT_START_CALLER_KEY = 'trusted-local:agent-start' -/** - * Every operation that reaches the provider finishes a stop an earlier attempt left owed first: the - * child that stop could not prove gone takes no input, and none may start beside it. Still - * unproven, the operation is refused with the exit `unverifiable`, never assumed `exited`. - */ -export async function finishOwedStructuredAgentSessionStop( - context: StructuredAgentSessionLifetimeContext, - sessionId: string -): Promise { - if (await finishOwedStructuredAgentSessionWindDownUnderSerialize(context, sessionId)) { - return { ok: true } - } - return { - ok: false, - refusal: refuse( - 'agent_session_ownership_unknown', - { reason: 'previousExitUnverifiable', ownerVerdict: 'unverifiable' }, - "Orca could not prove this chat's previous agent process exited." - ) - } -} - -/** The same, for an operation the running child performs, which starts none: only an exit still - * unproven, its child still on record, holds it back. A proven exit owes only bookkeeping, which - * gates no such operation: its retry was reported, and the operation goes on as with none owed. */ -export async function finishOwedStructuredAgentSessionStopForProviderWrite( - context: StructuredAgentSessionLifetimeContext, - sessionId: string -): Promise { - const settled = await finishOwedStructuredAgentSessionStop(context, sessionId) - return settled.ok || context.sessions.get(sessionId)?.child ? settled : { ok: true } -} - -export function isStructuredAgentSessionPreviousExitUnverifiable( - refusal: AgentSessionWireRefusal -): boolean { - return ( - refusal.code === 'agent_session_ownership_unknown' && - refusal.details?.reason === 'previousExitUnverifiable' - ) -} - /** * Gives the session a provider child if it has none, for a caller inside its serialize — which is * what makes "if it has none" exact: two askers run this in turn, and the second finds the first @@ -104,9 +63,11 @@ export async function ensureStructuredAgentSessionAgent( sessionId: string, startedFor?: string ): Promise { - const settled = await finishOwedStructuredAgentSessionStop(context, sessionId) - if (!settled.ok) { - return settled + // A child a stop began closing takes no input, and none may start beside it: the start waits on + // that close, and is refused while its exit stays unverifiable. + const closed = await joinClosingStructuredAgentSessionChild(context, sessionId) + if (!closed.ok) { + return closed } if (context.sessions.get(sessionId)?.child) { return { ok: true } @@ -158,6 +119,9 @@ async function startStructuredAgentSessionAgent( return { ok: false, refusal: unreconciled } } await context.runtimeState.resolveRecovery(sessionId) + // A start needs the lease released; a release the exit's own wind-down could not write is + // re-derived from this host's proof of that exit, never refused on. + await releaseLeaseOfEndedStructuredAgentSessionChild(context, sessionId) const record = context.deps.store.getRecord(sessionId) if (!record) { return refuseResume( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index 77a6fa3434e0..75d2a44e1b89 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -73,6 +73,21 @@ export function mutateWithChatStop( agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) ) const child = context.sessions.get(ctx.sessionId)?.child + if (child?.close) { + // A close an earlier stop began: this Stop joins it, retrying the exit's proof, rather + // than asking a child that takes no input to stop again. + if (withdrawn.length > 0) { + await tookEffect() + } + await stopChild().catch((error: unknown) => + context.deps.logger.warn('ending the agent process on Stop failed', { + scope: 'stop-child', + sessionId, + error + }) + ) + return { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } + } if (child?.phase === 'starting') { // A start that may never land is the one thing here Stop has to end; the chat stays. await tookEffect() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts new file mode 100644 index 000000000000..27bcd9c6fb53 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts @@ -0,0 +1,263 @@ +// A provider child's close: one per child, joined by every stop, start and provider write that +// meets it, and the one place a close's proven exit ends the child's record. +// +// The close lives on the child (`child.close`) and ends with it, so nothing outlives the process +// it is about. A caller waits a bounded time for the exit's proof; past the bound it answers +// `unverifiable` and the close keeps running. Whenever the exit is proven — by a caller still +// waiting, or by the adapter's own report of it — the record ends here. + +import { withTimeout } from '../../../shared/promise-timeout-fallback' +import { refuse } from '../../../shared/agent-session-wire-refusals' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { + evictStructuredAgentSession, + STRUCTURED_AGENT_SESSION_EVICTION_STEPS +} from './structured-agent-session-eviction' +import { + STRUCTURED_AGENT_SESSION_EVICTION_STEP_TIMEOUT_MS, + withStructuredAgentSessionEvictionDeadline +} from './structured-agent-session-eviction-deadline' +import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' +import type { + StructuredAgentSessionChildClose, + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChild +} from './structured-agent-session-host-types' +import { endProviderChild } from './structured-agent-session-provider-child' +import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' +import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' +import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' +import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' +import { isSurfaceReleasableAgentSessionRecord } from '../../runtime/agent-session-surface-release-transition' + +/** What a caller learned about the child's exit: proven, or not within its bound. Loss of contact + * and a close that ran out of its own escalation both read `unverifiable`, never `exited`. */ +export type StructuredAgentSessionChildCloseVerdict = 'exited' | 'unverifiable' + +/** Joins the child's close, starting another attempt when the last one ended unproven, and waits + * for its proof as long as the caller may. A proof ends the record before this resolves. */ +export async function joinStructuredAgentSessionChildClose( + context: StructuredAgentSessionLifetimeContext, + sessionId: string, + child: StructuredAgentSessionProviderChild, + close: StructuredAgentSessionChildClose +): Promise { + close.attempt ??= closeAttempt(context, sessionId, close) + const proven = await withTimeout( + close.attempt, + STRUCTURED_AGENT_SESSION_EVICTION_STEP_TIMEOUT_MS, + false + ) + if (!proven) { + return 'unverifiable' + } + await endClosedStructuredAgentSessionChildUnderSerialize(context, sessionId, child) + return 'exited' +} + +/** The refusal of an operation that met a child whose close is still unproven. */ +export function previousExitUnverifiableRefusal(): AgentSessionWireRefusal { + return refuse( + 'agent_session_ownership_unknown', + { reason: 'previousExitUnverifiable', ownerVerdict: 'unverifiable' }, + "Orca could not prove this chat's previous agent process exited." + ) +} + +/** For an operation that reaches the provider: a child a stop began closing takes no input and + * none may start beside it, so the operation joins that close and is refused while it is still + * unproven. */ +export async function joinClosingStructuredAgentSessionChild( + context: StructuredAgentSessionLifetimeContext, + sessionId: string +): Promise<{ ok: true } | { ok: false; refusal: AgentSessionWireRefusal }> { + const child = context.sessions.get(sessionId)?.child + if (!child?.close) { + return { ok: true } + } + return (await joinStructuredAgentSessionChildClose(context, sessionId, child, child.close)) === + 'exited' + ? { ok: true } + : { ok: false, refusal: previousExitUnverifiableRefusal() } +} + +function closeAttempt( + context: StructuredAgentSessionLifetimeContext, + sessionId: string, + close: StructuredAgentSessionChildClose +): Promise { + const { adapter, logger } = context.deps + // An adapter with no close has nothing to stop; anything else must PROVE the exit. + const stop = adapter.disposeSession ?? adapter.closeSession + const attempt = ( + stop + ? stopAgentSessionProviderRoot( + () => stop.call(adapter, sessionId), + // The exit is proven; a child process left behind, or a write after it, is reported only. + (error) => + logger.warn("the agent's process exited, but its close did not finish cleanly", { + scope: 'provider-close-after-exit', + sessionId, + error + }) + ) + : Promise.resolve(true) + ).catch((error: unknown) => { + logger.warn("closing the agent's process did not prove it exited", { + scope: 'provider-close', + sessionId, + error + }) + return false + }) + // Unproven, the next ask starts another attempt. A proof that lands after every caller stopped + // waiting needs nothing here: the adapter reports that exit, which ends the record. + void attempt.then((proven) => { + if (!proven && close.attempt === attempt) { + close.attempt = undefined + } + }) + return attempt +} + +/** Whether the lease still names the child this host last proved gone, unreleased: only that + * in-memory proof lets this host release it without a probe, so the handle carrying it stays. */ +export function structuredAgentSessionEndedChildHoldsLease( + context: Pick, + sessionId: string +): boolean { + const ended = context.sessions.get(sessionId)?.lastEndedChild + const record = context.deps.store.getRecord(sessionId) + return ( + ended?.rootGone === true && + record !== null && + isSurfaceReleasableAgentSessionRecord(record) && + record.lease.runtimeFence === ended.fence + ) +} + +/** Writes the release a proven exit allows when its wind-down could not: a start and the + * handle's close re-derive it, and a failure is reported, never anyone's refusal. Resolves + * whether the lease still names that child. */ +export async function releaseLeaseOfEndedStructuredAgentSessionChild( + context: Pick, + sessionId: string +): Promise { + const ended = context.sessions.get(sessionId)?.lastEndedChild + if (!ended || !structuredAgentSessionEndedChildHoldsLease(context, sessionId)) { + return false + } + await releaseStoredStructuredAgentSessionOwner({ + store: context.deps.store, + sessionId, + hasProviderChild: true, + expectedFence: ended.fence, + now: context.now() + }).catch((error: unknown) => + context.deps.logger.warn("releasing an exited agent's lease failed", { + scope: 'ended-child-lease-release', + sessionId, + error + }) + ) + return structuredAgentSessionEndedChildHoldsLease(context, sessionId) +} + +/** The adapter's report that a child it was asked to close is gone ends the record: a proof that + * landed with no caller left waiting, or a root that exited after its close gave up. */ +export function endClosedStructuredAgentSessionChild( + context: StructuredAgentSessionLifetimeContext, + event: StructuredAgentSessionEndedEvent +): Promise { + return context.serialize(event.sessionId, async () => { + const child = context.sessions.get(event.sessionId)?.child + if (child && child.fence === event.fence && child.generation === event.acquisitionGeneration) { + await endClosedStructuredAgentSessionChildUnderSerialize(context, event.sessionId, child) + } + }) +} + +/** One wind-down per child, which every caller that saw its exit proven awaits. */ +const windDowns = new WeakMap>() + +/** + * The child's process is proven gone: it leaves the record, then every wind-down step is + * attempted, each failure reported, none able to keep it on record or gate the next send. Then + * the delivery loop hands over whatever is queued. Joins the wind-down already running for it. + */ +export function endClosedStructuredAgentSessionChildUnderSerialize( + context: StructuredAgentSessionLifetimeContext, + sessionId: string, + child: StructuredAgentSessionProviderChild +): Promise { + const running = windDowns.get(child) + if (running) { + return running + } + const session = context.sessions.get(sessionId) + if (!session || session.child !== child) { + return Promise.resolve() + } + const windDown = windDownClosedChild(context, sessionId, session, child) + windDowns.set(child, windDown) + return windDown +} + +async function windDownClosedChild( + context: StructuredAgentSessionLifetimeContext, + sessionId: string, + session: StructuredAgentSessionHostSession, + child: StructuredAgentSessionProviderChild +): Promise { + const { deps } = context + const close = child.close + endProviderChild(session, { + generation: child.generation, + fence: child.fence, + cause: close?.cause ?? 'evict', + reason: close?.reason ?? null, + duringStartup: child.phase === 'starting', + rootGone: true + }) + context.restartWitness?.stopped(sessionId) + await evictStructuredAgentSession( + { + sessionId, + eventSink: context.runtimeState.eventSinkFor(sessionId), + logger: deps.logger, + acknowledgeRelease: () => deps.adapter.acknowledgeSessionRelease?.(sessionId), + discardSink: () => context.runtimeState.discardEventSink(sessionId), + settleWork: async () => { + // Folded before the fallback's end is built, so the end reads it (`turnEndAfterStop`). + await close?.recorded + const settled = await settleStructuredAgentSessionDeadGeneration({ + journal: session.journal, + sessionId, + fence: child.fence, + settlementId: `expected-close:${sessionId}:${child.fence}:${child.generation ?? 'unknown'}`, + pendingSubmissionReason: 'provider_closed_before_acknowledgement', + // Only a turn no adapter settled: one with no close, or whose settle threw. Whether it + // was a person's Stop is its event's to say (`turnEndAfterStop`). + verdict: { state: 'interrupted', completedAt: context.now() }, + showUnexpectedExitOutcome: false + }) + if (!settled.ok) { + throw new Error('dead generation work settlement failed', { cause: settled.error }) + } + }, + releaseLease: () => + releaseStoredStructuredAgentSessionOwner({ + store: deps.store, + sessionId, + hasProviderChild: true, + expectedFence: child.fence, + now: context.now() + }) + }, + withStructuredAgentSessionEvictionDeadline(STRUCTURED_AGENT_SESSION_EVICTION_STEPS) + ) + // Whatever ended the child, the row belongs to the conversation: it shows not-running, and only + // the conversation's close forgets it. + context.publishStatus?.(sessionId) + context.wakeDelivery?.(sessionId) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index f09fe672b5ec..1867e4f4729f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -135,7 +135,8 @@ describe('Claude root-exit stop', () => { runtimeState, sessions, now: () => NOW + 30 * 60_000, - publishStatus + publishStatus, + serialize: (_sessionId, task) => task() }, 'session-1', { cause: 'evict' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts new file mode 100644 index 000000000000..4e2ca9cf4b15 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -0,0 +1,470 @@ +// A Claude child's close and its exit, on the shipping adapter and host. A Stop begins the child's +// close; every later stop, start and provider write joins it; the exit, whenever it is proven, ends +// the child's record; and a close still unverifiable fails what needed the child, holding nothing. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID, + type FakeConnection +} from '../../claude/claude-structured-session-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { STRUCTURED_AGENT_SESSION_EVICTION_STEP_TIMEOUT_MS } from './structured-agent-session-eviction-deadline' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] + +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let claude: ReturnType +let log: ReturnType +let persistHandle: ReturnType Promise>> + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-stop-exit-ends-record-')) + resetHostTestOperationIds() + log = recordingStructuredAgentSessionLogger() + persistHandle = vi.fn(async () => undefined) + claude = fakeClaude({ + replayUuid: null, + routes: { interrupt: () => ({ still_queued: [], cancelled: [] }) } + }) + const lifecycle: Promise[] = [] + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, + continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + lifecycle.push(host.handleAdapterEvent(mapped)) + } + }, + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + persistHandle: () => persistHandle(), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + logger: log.logger, + // Ticked by hand only, and with no idle window: a tick reaps whatever is at rest. + idleSweep: { intervalMs: 3_600_000, idleMs: 0 }, + now: () => NOW + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + await adapter.awaitStarted(SESSION) + await Promise.all(lifecycle) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function eventually(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +function envelope( + method: + | 'agentSession.send' + | 'agentSession.cancel' + | 'agentSession.setOption' + | 'agentSession.queuedMessageSend', + // The fingerprint's own field shape, as the sibling host tests type it. + fields: Parameters[0]['fields'] +) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error(`send refused: ${JSON.stringify(sent.refusal)}`) + } + return sent.value.clientMessageId +} + +async function submission(clientMessageId: string) { + await host.flushStreamedEvents(SESSION) + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +function frame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +function wrote(connection: FakeConnection, text: string): boolean { + return connection.sent.some((message) => JSON.stringify(message).includes(text)) +} + +async function openTurn(connection: FakeConnection): Promise { + const text = 'Write a long reply.' + const clientMessageId = await send(text) + await eventually(() => expect(wrote(connection, text)).toBe(true)) + frame(connection, { + type: 'system', + subtype: 'init', + uuid: 'init-1', + model: 'claude-sonnet-5', + capabilities: CAPABILITIES + }) + const written = connection.sent.at(-1)! + frame(connection, { ...written, uuid: written.uuid }) + frame(connection, { + type: 'assistant', + uuid: 'stopped-turn-leaf', + parent_tool_use_id: null, + message: { id: 'msg-1', role: 'assistant', content: [{ type: 'text', text: 'Working on' }] } + }) + await eventually(async () => + expect((await submission(clientMessageId))?.dispatchState).toBe('accepted') + ) + expect( + activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items) + ).not.toBeNull() +} + +function stop() { + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) +} + +function laneDrained(): Promise { + return host['tasks'].serialize(SESSION, async () => {}) +} + +/** As the real connection: once a close begins it refuses every write, proven or not. */ +function closeUnprovenFor(connection: FakeConnection, failures: number): void { + const close = connection.close + let left = failures + connection.close = async () => { + if (left === 0) { + return close() + } + left -= 1 + connection.closeCount += 1 + connection.closed = true + return false + } + refuseWritesOnceClosed(connection) +} + +function refuseWritesOnceClosed(connection: FakeConnection): void { + const write = connection.send + connection.send = (message, beforeDispatch) => + connection.closed + ? Promise.reject( + claudeUnwrittenUserMessageError(new Error('claude stream-json connection is closed')) + ) + : write(message, beforeDispatch) +} + +const INTERRUPTED_RESULT = { + type: 'result', + subtype: 'error_during_execution', + is_error: true, + terminal_reason: 'aborted_streaming', + uuid: 'interrupted-result' +} + +function child() { + return host['sessions'].get(SESSION)?.child ?? null +} + +function lease() { + return store.getRecord(SESSION)?.lease +} + +/** Stops a running turn; its child's close cannot prove the exit `failures` times. */ +async function stopWithUnprovenClose(failures: number): Promise { + const connection = claude.connections[0]! + await openTurn(connection) + closeUnprovenFor(connection, failures) + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) + await laneDrained() + expect(connection.closeCount).toBe(1) + // The child stays on record with its close begun, never as a stored obligation beside it. + expect(child()?.close).toMatchObject({ cause: 'user-stop' }) + return connection +} + +async function resumedWith(connection: FakeConnection, text: string): Promise { + return eventually(() => { + const started = claude.connections.at(-1)! + expect(started).not.toBe(connection) + expect(wrote(started, text)).toBe(true) + return started + }) +} + +function setModel(model: string) { + const fields = { key: 'model', value: model } + return host.setOption(CALLER, { envelope: envelope('agentSession.setOption', fields), ...fields }) +} + +function deferred() { + let resolve: (value: T) => void = () => undefined + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +function scopes(): unknown[] { + return log.entries.map((entry) => entry.fields.scope) +} + +it('joins the close a Stop could not prove before the next message, then sends it to a resumed child', async () => { + const connection = await stopWithUnprovenClose(1) + + await send('Carry on.') + await resumedWith(connection, 'Carry on.') + + expect(connection.closeCount).toBe(2) + expect(wrote(connection, 'Carry on.')).toBe(false) + expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ + cause: 'user-stop', + rootGone: true + }) +}) + +it('has a send made while the close still runs join that close, not start another', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + const proof = deferred() + const close = connection.close + let attempts = 0 + connection.close = async () => { + attempts += 1 + connection.closed = true + await proof.promise + return close() + } + refuseWritesOnceClosed(connection) + const closeSession = vi.spyOn(adapter, 'closeSession') + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) + + await send('Carry on.') + await eventually(() => expect(attempts).toBe(1)) + expect(claude.connections).toHaveLength(1) + proof.resolve() + + await resumedWith(connection, 'Carry on.') + // One close of the old child, which the send waited on rather than asking for its own. + expect(closeSession).toHaveBeenCalledOnce() + expect(attempts).toBe(1) + expect(connection.closeCount).toBe(1) +}) + +// The caller's bound is how long it waits, never how long the close may take. +it('ends the record when the proof lands after every caller stopped waiting', async () => { + const connection = claude.connections[0]! + const proof = deferred() + connection.close = async () => { + connection.closed = true + return proof.promise + } + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + let stopped: unknown + try { + const stopping = host['tasks'] + .serialize(SESSION, () => host['lifetime'].stopAgent(SESSION, { cause: 'evict' })) + .then( + () => 'proven', + (error: unknown) => error + ) + await vi.advanceTimersByTimeAsync(STRUCTURED_AGENT_SESSION_EVICTION_STEP_TIMEOUT_MS + 2_000) + stopped = await stopping + } finally { + vi.useRealTimers() + } + // Unverifiable for that caller, and the close still running: nothing claims the child exited. + expect(stopped).toMatchObject({ step: 'stop-provider-child' }) + expect(child()?.close?.attempt).toBeDefined() + expect(lease()?.claimStatus).toBe('live') + + proof.resolve(true) + + await eventually(() => expect(child()).toBeNull()) + expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ cause: 'evict' }) + await eventually(() => expect(lease()?.claimStatus).toBe('released')) +}) + +it('ends the record when the root exits after its close gave up, with no one asking again', async () => { + const connection = await stopWithUnprovenClose(1) + + // The connection reports the root's exit as the end of the close Orca began. + connection.handlers.onExit?.(new Error('claude exited'), { expected: true }) + + await eventually(() => expect(child()).toBeNull()) + await eventually(() => expect(lease()?.claimStatus).toBe('released')) + expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ cause: 'user-stop' }) + expect(connection.closeCount).toBe(2) + expect(claude.connections).toHaveLength(1) +}) + +it('sends after a proven exit whose resume-point write and lease release both failed', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + refuseWritesOnceClosed(connection) + persistHandle.mockRejectedValueOnce(new Error('resume point not written')) + const transition = store.transitionHandoff.bind(store) + vi.spyOn(store, 'transitionHandoff') + .mockImplementationOnce(async () => { + throw new Error('store unavailable') + }) + .mockImplementation(transition) + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) + await eventually(() => expect(child()).toBeNull()) + // Both reported; neither kept the dead child on record. + await eventually(() => + expect(scopes()).toEqual( + expect.arrayContaining(['provider-close-after-exit', 'exit-wind-down']) + ) + ) + expect(lease()?.claimStatus).toBe('live') + + // The start writes the release from this host's proof of that exit. + await send('Carry on.') + await resumedWith(connection, 'Carry on.') +}) + +it('rejects a message whose start meets a close still unverifiable, and starts nothing beside it', async () => { + const connection = await stopWithUnprovenClose(2) + + const next = await send('Carry on.') + + await eventually(async () => expect((await submission(next))?.dispatchState).toBe('rejected')) + const rejected = (await submission(next))! + expect(rejected.reason).toBe( + "Orca couldn't confirm Claude's previous process ended. Send your message to try again." + ) + expect(rejected.rejection).toMatchObject({ + kind: 'restartFailed', + refusal: { details: { reason: 'previousExitUnverifiable' } } + }) + // Rejected, never held: no waiting note, and the old child still on record. + const notes = (await host.journalSnapshot(SESSION)).items.filter( + (item) => item.body.kind === 'status' && item.body.failure?.kind === 'previousExitUnverifiable' + ) + expect(notes).toEqual([]) + expect(connection.closeCount).toBe(2) + expect(claude.connections).toHaveLength(1) + expect(wrote(connection, 'Carry on.')).toBe(false) + expect(child()?.close).toBeDefined() +}) + +it('refuses an option change while the close stays unverifiable, never writing to the old child', async () => { + const connection = await stopWithUnprovenClose(2) + + await expect(setModel('claude-opus-5')).resolves.toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_ownership_unknown', + details: { reason: 'previousExitUnverifiable', ownerVerdict: 'unverifiable' } + } + }) + expect(connection.closeCount).toBe(2) + expect(connection.calls.some((call) => call.subtype === 'set_model')).toBe(false) + + // Joined again once the exit is proven, the pick is the chat's at rest, for the next start. + await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) + expect(child()).toBeNull() + expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'claude-opus-5' }) +}) + +it('reports a descendant left behind by a proven root exit, and blocks nothing', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + refuseWritesOnceClosed(connection) + connection.close = async () => { + connection.closeCount += 1 + connection.closed = true + connection.exitVerdict = { root: 'exited', tree: 'live' } + return false + } + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) + + await eventually(() => expect(child()).toBeNull()) + expect(scopes()).toContain('provider-close-after-exit') + await send('Carry on.') + await resumedWith(connection, 'Carry on.') +}) + +it('has quit join a close still unverifiable, and hand the lease back once it proves', async () => { + const connection = await stopWithUnprovenClose(1) + + await host.flushAllStreamedEvents() + + expect(connection.closeCount).toBe(2) + expect(lease()).toMatchObject({ claimStatus: 'released', ownerProcess: null }) +}) + +it('has the idle reaper join a close still unverifiable, ending the record with the Stop it finishes', async () => { + const connection = await stopWithUnprovenClose(1) + + await host['lifetime'].idleSweep.tick() + + expect(connection.closeCount).toBe(2) + expect(lease()).toMatchObject({ claimStatus: 'released', ownerProcess: null }) + expect(host.hasSession(SESSION)).toBe(false) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts deleted file mode 100644 index b86e25674641..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ /dev/null @@ -1,569 +0,0 @@ -// A Claude Stop whose close could not prove the child gone, then the user's next message, on the -// shipping adapter and host. That child takes no input, so the send retries the stop first; still -// unproven, the message waits with its reason until a later retry proves the exit. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' -import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' -import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' -import { - fakeClaude, - PROVIDER_SESSION_ID, - type FakeConnection -} from '../../claude/claude-structured-session-test-support' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' - -const CALLER = { callerKey: 'client-1' } -const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] - -let root: string -let host: StructuredAgentSessionHost -let adapter: ClaudeStructuredSessionAdapter -let store: AgentSessionRecordStore -let claude: ReturnType -let log: ReturnType - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-claude-unproven-stop-send-')) - resetHostTestOperationIds() - log = recordingStructuredAgentSessionLogger() - claude = fakeClaude({ - replayUuid: null, - routes: { interrupt: () => ({ still_queued: [], cancelled: [] }) } - }) - const lifecycle: Promise[] = [] - adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => ({ - pathToClaudeCodeExecutable: 'claude', - options: {}, - cwd: root, - claudeConfigDir: join(root, 'claude-home'), - providerSessionId: PROVIDER_SESSION_ID, - resumeLeafUuid: null, - resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, - continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 - }), - onEvent: (event) => { - const mapped = structuredClaudeLifecycleEvent(event) - if (mapped) { - lifecycle.push(host.handleAdapterEvent(mapped)) - } - }, - onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), - openConnection: claude.openConnection, - readProcessStartTime: async () => 1_700_000_000_000, - now: () => NOW - }) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - store, - adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - logger: log.logger, - now: () => NOW - }) - const params = hostTestAttachParams(null, { - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, - providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } - }) - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) - await Promise.all(lifecycle) -}) - -afterEach(async () => { - await adapter.closeAll() - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function eventually(assertion: () => T | Promise): Promise { - return vi.waitFor(assertion, { timeout: 10_000 }) -} - -function envelope( - method: - | 'agentSession.send' - | 'agentSession.cancel' - | 'agentSession.setOption' - | 'agentSession.queuedMessageSend', - // The fingerprint's own field shape, as the sibling host tests type it. - fields: Parameters[0]['fields'] -) { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -async function send(text: string): Promise { - const body = hostTestMessage(text) - const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - if (!sent.ok) { - throw new Error(`send refused: ${JSON.stringify(sent.refusal)}`) - } - return sent.value.clientMessageId -} - -async function submission(clientMessageId: string) { - await host.flushStreamedEvents(SESSION) - return (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.clientMessageId === clientMessageId - ) -} - -function frame(connection: FakeConnection, message: Record): void { - connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) -} - -function wrote(connection: FakeConnection, text: string): boolean { - return connection.sent.some((message) => JSON.stringify(message).includes(text)) -} - -async function openTurn(connection: FakeConnection): Promise { - const text = 'Write a long reply.' - const clientMessageId = await send(text) - await eventually(() => expect(wrote(connection, text)).toBe(true)) - frame(connection, { - type: 'system', - subtype: 'init', - uuid: 'init-1', - model: 'claude-sonnet-5', - capabilities: CAPABILITIES - }) - const written = connection.sent.at(-1)! - frame(connection, { ...written, uuid: written.uuid }) - frame(connection, { - type: 'assistant', - uuid: 'stopped-turn-leaf', - parent_tool_use_id: null, - message: { id: 'msg-1', role: 'assistant', content: [{ type: 'text', text: 'Working on' }] } - }) - await eventually(async () => - expect((await submission(clientMessageId))?.dispatchState).toBe('accepted') - ) - expect( - activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items) - ).not.toBeNull() -} - -function stop() { - return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) -} - -function laneDrained(): Promise { - return host['tasks'].serialize(SESSION, async () => {}) -} - -/** A commit queues a serialized wake, and the wake queues its delivery step behind that. */ -async function commitSettled(): Promise { - await laneDrained() - await laneDrained() -} - -/** As the real connection: once a close begins it refuses every write, proven or not. */ -function closeUnprovenFor(connection: FakeConnection, failures: number): void { - const close = connection.close - let left = failures - connection.close = async () => { - if (left === 0) { - return close() - } - left -= 1 - connection.closeCount += 1 - connection.closed = true - return false - } - const write = connection.send - connection.send = (message, beforeDispatch) => - connection.closed - ? Promise.reject( - claudeUnwrittenUserMessageError(new Error('claude stream-json connection is closed')) - ) - : write(message, beforeDispatch) -} - -const INTERRUPTED_RESULT = { - type: 'result', - subtype: 'error_during_execution', - is_error: true, - terminal_reason: 'aborted_streaming', - uuid: 'interrupted-result' -} - -/** Stops a turn whose child's close cannot prove the exit `failures` times, and lets the Stop's - * second step fail on it. */ -async function stopWithUnprovenClose(failures: number): Promise { - const connection = claude.connections[0]! - await openTurn(connection) - closeUnprovenFor(connection, failures) - await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) - frame(connection, INTERRUPTED_RESULT) - await laneDrained() - expect(connection.closeCount).toBe(1) - expect(owedWindDown()).toBeDefined() - return connection -} - -function owedWindDown() { - return host['sessions'].get(SESSION)?.owesProviderChildWindDown -} - -async function waitRows() { - await host.flushStreamedEvents(SESSION) - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => - item.body.kind === 'status' && item.body.failure?.kind === 'previousExitUnverifiable' - ? [item.body] - : [] - ) -} - -async function resumedWith(connection: FakeConnection, text: string): Promise { - return eventually(() => { - const started = claude.connections.at(-1)! - expect(started).not.toBe(connection) - expect(wrote(started, text)).toBe(true) - return started - }) -} - -it('retries the close a Stop could not prove before the next message, then sends it to a resumed child', async () => { - const connection = await stopWithUnprovenClose(1) - - const next = await send('Carry on.') - const resumed = await resumedWith(connection, 'Carry on.') - - expect(connection.closeCount).toBe(2) - expect(wrote(connection, 'Carry on.')).toBe(false) - expect(resumed.closed).toBe(false) - expect(owedWindDown()).toBeUndefined() - expect((await submission(next))?.dispatchState).toBe('pending') - expect(await waitRows()).toEqual([]) -}) - -it('holds the message with its reason while the exit stays unverifiable, and sends it once a later retry proves it', async () => { - const connection = await stopWithUnprovenClose(3) - - // Never refused: the send is accepted and returns at once. - const next = await send('Carry on.') - await eventually(async () => expect(await waitRows()).toHaveLength(1)) - await laneDrained() - expect(await waitRows()).toEqual([ - { - kind: 'status', - tone: 'warning', - text: "Orca couldn't confirm Claude's previous process ended. Messages wait to be sent until Orca confirms it has ended.", - failure: { kind: 'previousExitUnverifiable' } - } - ]) - // Still queued, drawn below the chat; never written to the child the Stop could not end. - expect(await submission(next)).toMatchObject({ dispatchState: 'pending' }) - expect((await submission(next))?.handedOverAt).toBeUndefined() - expect(claude.connections).toHaveLength(1) - expect(wrote(connection, 'Carry on.')).toBe(false) - expect(owedWindDown()).toBeDefined() - // The Stop's attempt and the send's one retry: the row's own commit retries nothing. - expect(connection.closeCount).toBe(2) - - // A second message retries once more, and waits under the same row. - const second = await send('And this.') - await eventually(() => expect(connection.closeCount).toBe(3)) - await laneDrained() - expect(await submission(second)).toMatchObject({ dispatchState: 'pending' }) - expect(await waitRows()).toHaveLength(1) - - // The sweep's next tick retries the stop; the exit is proven and both messages go out. - await host['lifetime'].idleSweep.tick() - const resumed = await resumedWith(connection, 'Carry on.') - await eventually(() => expect(wrote(resumed, 'And this.')).toBe(true)) - expect(connection.closeCount).toBe(4) - expect(owedWindDown()).toBeUndefined() - expect(await waitRows()).toHaveLength(1) -}) - -it('lets a held message be withdrawn with Stop, and the owed stop still ends on the next retry', async () => { - const connection = await stopWithUnprovenClose(2) - const next = await send('Carry on.') - await eventually(async () => expect(await waitRows()).toHaveLength(1)) - await laneDrained() - - await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) - expect(await submission(next)).toMatchObject({ dispatchState: 'rejected' }) - expect(owedWindDown()).toBeDefined() - - await host['lifetime'].idleSweep.tick() - expect(connection.closeCount).toBe(3) - expect(owedWindDown()).toBeUndefined() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - // Nothing was waiting, so no child starts. - expect(claude.connections).toHaveLength(1) - expect(wrote(connection, 'Carry on.')).toBe(false) -}) - -function setModel(model: string) { - const fields = { key: 'model', value: model } - return host.setOption(CALLER, { envelope: envelope('agentSession.setOption', fields), ...fields }) -} - -it('refuses an option change with the exit unverifiable after retrying the stop, never writing to the old child', async () => { - const connection = await stopWithUnprovenClose(2) - - await expect(setModel('claude-opus-5')).resolves.toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_ownership_unknown', - details: { reason: 'previousExitUnverifiable', ownerVerdict: 'unverifiable' } - } - }) - expect(connection.closeCount).toBe(2) - expect(connection.calls.some((call) => call.subtype === 'set_model')).toBe(false) - expect(owedWindDown()).toBeDefined() - - // Trying again retries the stop; proven, the pick is the chat's at rest, for the next start. - await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) - expect(connection.closeCount).toBe(3) - expect(owedWindDown()).toBeUndefined() - expect(connection.calls.some((call) => call.subtype === 'set_model')).toBe(false) - expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'claude-opus-5' }) - expect(claude.connections).toHaveLength(1) -}) - -function stopBackgroundTasks() { - const fields = { scope: 'background-tasks' as const } - return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) -} - -/** Holds the user's next message on a Stop whose exit stays unproven through the send's retry. */ -async function heldAfterStop(): Promise { - const connection = await stopWithUnprovenClose(2) - await send('Carry on.') - await eventually(async () => expect(await waitRows()).toHaveLength(1)) - await laneDrained() - expect(connection.closeCount).toBe(2) - return connection -} - -it('sends a held message once an option change proves the exit: the stop that lands hands it over', async () => { - const connection = await heldAfterStop() - - await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) - expect(connection.closeCount).toBe(3) - const resumed = await resumedWith(connection, 'Carry on.') - expect(resumed.closed).toBe(false) - expect(owedWindDown()).toBeUndefined() - expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'claude-opus-5' }) -}) - -it('sends a held message once a background-task stop proves the exit, though that stop finds no agent', async () => { - const connection = await heldAfterStop() - - // Proven gone, the old agent has no tasks left to stop, and the message still goes out. - await expect(stopBackgroundTasks()).resolves.toMatchObject({ - ok: false, - refusal: { details: { reason: 'noLiveOwner' } } - }) - expect(connection.closeCount).toBe(3) - await resumedWith(connection, 'Carry on.') - expect(owedWindDown()).toBeUndefined() -}) - -it('sends a message held after a tab close whose exit was unproven, rather than rejecting it as closed', async () => { - const connection = claude.connections[0]! - closeUnprovenFor(connection, 2) - await expect(host.close(SESSION, 'user-close')).rejects.toThrow() - expect(owedWindDown()).toMatchObject({ cause: 'user-close' }) - - // The chat stays open on the host; the user sends again, and the message waits on that close. - const next = await send('Carry on.') - await eventually(async () => expect(await waitRows()).toHaveLength(1)) - await laneDrained() - expect(connection.closeCount).toBe(2) - - // The close was asked for before this message, so the retry that lands ends nothing it waited on. - await host['lifetime'].idleSweep.tick() - await laneDrained() - expect(await submission(next)).not.toMatchObject({ dispatchState: 'rejected' }) - await resumedWith(connection, 'Carry on.') - expect(owedWindDown()).toBeUndefined() -}) - -it('never stops a live child for a wind-down another, earlier child still owes', async () => { - const connection = claude.connections[0]! - const session = host['sessions'].get(SESSION)! - session.owesProviderChildWindDown = { - generation: 'an-earlier-child', - fence: session.child!.fence, - cause: 'user-stop', - requestedAt: session.journal.cursor() - } - - await send('Carry on.') - await eventually(() => expect(wrote(connection, 'Carry on.')).toBe(true)) - await laneDrained() - await host['lifetime'].idleSweep.tick() - expect(connection.closeCount).toBe(0) - expect(claude.connections).toHaveLength(1) -}) - -it('retries once per new message: commits after a second waiting message retry nothing', async () => { - const connection = await stopWithUnprovenClose(6) - await send('Carry on.') - await eventually(async () => expect(await waitRows()).toHaveLength(1)) - await laneDrained() - await send('And this.') - await eventually(() => expect(connection.closeCount).toBe(3)) - await laneDrained() - - // Any journal commit wakes the delivery loop while a message is queued. - const session = host['sessions'].get(SESSION)! - for (const n of [1, 2, 3]) { - await session.journal.appendItem( - { provider: 'orca', clientMessageId: `unrelated-${n}` }, - { kind: 'status', text: `Unrelated ${n}.` }, - { fence: store.getRecord(SESSION)!.lease.runtimeFence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await commitSettled() - } - expect(connection.closeCount).toBe(3) - expect(claude.connections).toHaveLength(1) - - // The sweep still retries, one pass a tick, until the exit is proven; then both go out. - for (const _tick of [1, 2, 3, 4]) { - await host['lifetime'].idleSweep.tick() - } - expect(connection.closeCount).toBe(7) - const resumed = await resumedWith(connection, 'Carry on.') - await eventually(() => expect(wrote(resumed, 'And this.')).toBe(true)) -}) - -it('queues a follow-up as a draft by default while a message waits, and Steer retries the stop once for it', async () => { - const connection = await stopWithUnprovenClose(3) - await send('Carry on.') - await eventually(async () => expect(await waitRows()).toHaveLength(1)) - await laneDrained() - - // Queueing follow-ups is the default: the waiting message reads as working, so this is a draft. - const body = hostTestMessage('And this.') - const delivery = 'queue-if-active' as const - const queued = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body, delivery }), - body, - delivery - }) - expect(queued).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) - await commitSettled() - expect(connection.closeCount).toBe(2) - - // Steer makes it a waiting message, which retries once and waits under the same note. - const messageId = queued.ok && 'queued' in queued.value ? queued.value.queued.messageId : '' - await expect( - host.queuedMessageSend(CALLER, { - envelope: envelope('agentSession.queuedMessageSend', { messageId }), - messageId - }) - ).resolves.toMatchObject({ ok: true }) - await eventually(() => expect(connection.closeCount).toBe(3)) - await commitSettled() - expect(connection.closeCount).toBe(3) - expect(claude.connections).toHaveLength(1) - expect(await waitRows()).toHaveLength(1) - - await host['lifetime'].idleSweep.tick() - const resumed = await resumedWith(connection, 'Carry on.') - await eventually(() => expect(wrote(resumed, 'And this.')).toBe(true)) -}) - -it('rejects as closed a message a second tab close closed, though that close could not reject it itself', async () => { - const connection = claude.connections[0]! - closeUnprovenFor(connection, 3) - await expect(host.close(SESSION, 'user-close')).rejects.toThrow() - const next = await send('Carry on.') - await eventually(async () => expect(await waitRows()).toHaveLength(1)) - await laneDrained() - expect(connection.closeCount).toBe(2) - - // The second close's own rejection of what is queued fails; its stop is a new ask all the same. - const session = host['sessions'].get(SESSION)! - vi.spyOn(session.journal, 'rejectQueuedSubmissions').mockRejectedValueOnce(new Error('disk full')) - await expect(host.close(SESSION, 'user-close')).rejects.toThrow() - expect(connection.closeCount).toBe(3) - expect(await submission(next)).toMatchObject({ dispatchState: 'pending' }) - - // The retry that proves the exit closes what that second close closed. - await host['lifetime'].idleSweep.tick() - await commitSettled() - expect(connection.closeCount).toBe(4) - expect(await submission(next)).toMatchObject({ dispatchState: 'rejected' }) - expect(claude.connections).toHaveLength(1) -}) - -it('notes why a message waits when another operation failed its retry before the message was delivered', async () => { - const connection = await stopWithUnprovenClose(2) - - // The send is accepted, then the option change's failed retry runs before the send's delivery. - const sent = send('Carry on.') - const option = setModel('claude-opus-5') - await expect(option).resolves.toMatchObject({ - ok: false, - refusal: { details: { reason: 'previousExitUnverifiable' } } - }) - await sent - await commitSettled() - expect(connection.closeCount).toBe(2) - expect(await waitRows()).toHaveLength(1) - expect(claude.connections).toHaveLength(1) -}) - -it('keeps an option change at rest when the Stop proved the exit and only its bookkeeping keeps failing', async () => { - const connection = claude.connections[0]! - await openTurn(connection) - // The close proves the exit; draining what the old agent wrote fails for the Stop. - const barrierLost = { ok: false as const, error: new Error('drain barrier lost') } - const drained = vi - .spyOn(host['runtimeState'].eventSinkFor(SESSION), 'drained') - .mockResolvedValueOnce(barrierLost) - .mockResolvedValueOnce(barrierLost) - await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) - frame(connection, INTERRUPTED_RESULT) - await laneDrained() - expect(connection.closeCount).toBe(1) - expect(host['sessions'].get(SESSION)?.child).toBeNull() - expect(owedWindDown()).toBeDefined() - - // The option change's retry of that bookkeeping fails again: reported, and the pick still lands. - drained.mockResolvedValueOnce(barrierLost) - await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) - expect(owedWindDown()).toBeDefined() - expect(log.scopes().filter((scope) => scope === 'owed-stop-retry')).toHaveLength(1) - expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'claude-opus-5' }) - expect(connection.closeCount).toBe(1) - expect(claude.connections).toHaveLength(1) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index 33ef8080c030..491c4a419723 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -253,7 +253,7 @@ describe('a turn cut short by closing its provider', () => { ).toMatchObject({ key: 'failedAfter' }) }) - it("keeps the user's cancellation when a close aborts after the provider settled, then retries", async () => { + it("keeps the user's cancellation when a close's drain fails after the provider settled", async () => { await runningTurn() const sink = host['runtimeState'].eventSinkFor(SESSION) const drained = sink.drained.bind(sink) @@ -265,10 +265,9 @@ describe('a turn cut short by closing its provider', () => { } return drained() }) - // The adapter settled the turn, then the close aborted at the drain after it. - await expect(host.close(SESSION, 'user-close')).rejects.toThrow() + // The adapter settled the turn, then the drain after it failed: reported, and the close ends. + await expect(host.close(SESSION, 'user-close')).resolves.toBeUndefined() expect(closeCalls).toBe(1) - await host.close(SESSION, 'user-close') const { turn } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) }) @@ -277,7 +276,7 @@ describe('a turn cut short by closing its provider', () => { ['user-close', { outcome: 'cancellation' }], ['evict', { outcome: undefined }] ] as const)( - "keeps a %s's cause when the idle sweep finishes a wind-down it could not", + "keeps a %s's cause when the close's drain fails before the host settles its turn", async (cause, verdict) => { providerEnd = null await runningTurn() @@ -291,10 +290,8 @@ describe('a turn cut short by closing its provider', () => { } return drained() }) - // The provider is proven gone, then the close aborts before the host settles its turn. - await expect(host.close(SESSION, cause)).rejects.toThrow() - - await host.collaboratorsForTests().lifetime.idleSweep.tick() + // The provider is proven gone, then the drain fails: the host still settles the turn. + await expect(host.close(SESSION, cause)).resolves.toBeUndefined() expect(closeCalls).toBe(1) const { turn } = await settledTurn() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index ef254fdc765c..6f7ef7ee7d1b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -58,12 +58,12 @@ beforeEach(async () => { return {} } const store = await openTestAgentSessionRecordStore(root) - // The runtime's wiring: an echo accepts its send, and an exit reaches the host. + // The runtime's wiring: an echo accepts its send, and every exit reaches the host. launch = {} const adapter = adapterFor(codex, launch, [], { onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), onEvent: (event) => { - if (event.type === 'ended' && 'cause' in event && event.cause === 'unexpected-exit') { + if (event.type === 'ended' && 'cause' in event) { void host.handleAdapterEvent(event) } } @@ -448,16 +448,17 @@ describe('a Codex Stop whose interrupt failed', () => { }) describe('a message after a Codex Stop whose exit was unproven', () => { - it('retries that stop first, then goes to a fresh Codex, never to the old one', async () => { + /** A Stop whose interrupt fails ends the child, whose close cannot prove the exit `failures` + * times. As the real connection, it refuses every request once a close begins. */ + async function stopWithUnprovenClose(failures: number) { await runningTurn() codex.routes['turn/interrupt'] = () => { throw interruptFailure('internal error') } - // As the real connection: once a close begins it refuses every request, proven or not. const old = codex.connections.at(-1)! const close = old.close const request = old.request - let unproven = 1 + let unproven = failures old.close = async () => { old.closed = true if (unproven === 0) { @@ -470,28 +471,66 @@ describe('a message after a Codex Stop whose exit was unproven', () => { old.closed ? Promise.reject(new Error('codex app-server is closing')) : request(method, params) - await stop() await host.flushStreamedEvents(SESSION) - expect(host['sessions'].get(SESSION)?.owesProviderChildWindDown).toBeDefined() + return old + } + + const startedWith = (connection: (typeof codex.connections)[number], text: string) => + connection.calls.some( + (call) => call.method === 'turn/start' && JSON.stringify(call.params).includes(text) + ) + + it('rejects the message while the close stays unverifiable, and starts no Codex beside it', async () => { + const old = await stopWithUnprovenClose(2) + + const sent = await send('carry on') + if (!sent.ok || !('submission' in sent.value)) { + throw new Error(JSON.stringify(sent)) + } + const id = sent.value.submission.clientMessageId + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) + ).toMatchObject({ + dispatchState: 'rejected', + reason: + "Orca couldn't confirm Codex's previous process ended. Send your message to try again." + }) + ) + expect(codex.connections).toHaveLength(1) + expect(startedWith(old, 'carry on')).toBe(false) + expect(host['sessions'].get(SESSION)?.child?.close).toBeDefined() + }) + + it('ends the record when the root exits after its close gave up, with no one asking again', async () => { + const old = await stopWithUnprovenClose(1) + const handled = vi.spyOn(host, 'handleAdapterEvent') + + old.handlers.onExit?.(new Error('codex app-server exited'), { expected: true }) + + // The close Orca began, ended: Orca's, never blamed on Codex as a crash. + expect(handled).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'requested-close', failure: { kind: 'hostFault' } }) + ) + await vi.waitFor(() => expect(host['sessions'].get(SESSION)?.child).toBeNull()) + expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ cause: 'user-stop' }) + expect(codex.connections).toHaveLength(1) + }) + + it('joins that close first, then goes to a fresh Codex, never to the old one', async () => { + const old = await stopWithUnprovenClose(1) + expect(host['sessions'].get(SESSION)?.child?.close).toMatchObject({ cause: 'user-stop' }) // The next start resumes the chat's thread, as the runtime's launch resolves it from the record. launch.resumeThreadId = THREAD const sent = await send('carry on') expect(sent).toMatchObject({ ok: true }) await vi.waitFor(() => expect(codex.connections).toHaveLength(2)) - await vi.waitFor(() => - expect( - codex.connections[1]!.calls.some( - (call) => call.method === 'turn/start' && JSON.stringify(call.params).includes('carry on') - ) - ).toBe(true) - ) - expect( - old.calls.some( - (call) => call.method === 'turn/start' && JSON.stringify(call.params).includes('carry on') - ) - ).toBe(false) - expect(host['sessions'].get(SESSION)?.owesProviderChildWindDown).toBeUndefined() + await vi.waitFor(() => expect(startedWith(codex.connections[1]!, 'carry on')).toBe(true)) + expect(startedWith(old, 'carry on')).toBe(false) + expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ cause: 'user-stop' }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index c084d2b2e07c..72aafc92baf2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -19,7 +19,6 @@ import { sweepOnce, type RestTestRig } from './structured-agent-session-rest-test-rig' -import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { hostTestAttachParams } from './structured-agent-session-host-test-data' let rig: RestTestRig @@ -57,9 +56,8 @@ describe('a stop that fails', () => { rig.clock.now += IDLE_MS + 1 await sweepOnce(rig.host) - expect(openSession()?.owesProviderChildWindDown).toMatchObject({ - generation: expect.any(String) - }) + // The child stays on record with its close unproven, and the next tick joins it. + expect(openSession()?.child?.close).toMatchObject({ cause: 'evict' }) await sweepOnce(rig.host) expect(rig.adapter.closeSession).toHaveBeenCalledTimes(2) @@ -67,23 +65,21 @@ describe('a stop that fails', () => { expect(statusRows().at(-1)?.hostExecutionOwned).toBeUndefined() }) - it('finishes its wind-down on the next tick, whatever its own writes did to the clock (P2-11 b)', async () => { + it('re-derives a release its wind-down could not write before the handle closes (P2-11 b)', async () => { await foundRestTestChat(rig) rig.clock.now += IDLE_MS + 1 - // The child is proven gone and its work settled, then handing the lease back fails. + // The child is proven gone and its work settled, then handing the lease back fails once. const transition = vi .spyOn(rig.store, 'transitionHandoff') .mockRejectedValueOnce(new Error('store unavailable')) await sweepOnce(rig.host) - expect(transition).toHaveBeenCalledOnce() - expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - // Five minutes later, not thirty. - rig.clock.now += 5 * 60_000 - await sweepOnce(rig.host) + // Reported, never retried as a stop: the handle's close writes the release from the proof. + expect(transition).toHaveBeenCalledTimes(2) expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') expect(rig.adapter.closeSession).toHaveBeenCalledOnce() + expect(rig.host.hasSession(SESSION)).toBe(false) }) }) @@ -287,50 +283,3 @@ describe('a start that never finishes (P2-15)', () => { await expect(sweepOnce(rig.host)).resolves.toBeUndefined() }) }) - -describe('the wind-down retry with a message queued (P2-31)', () => { - it('waits for the delivery rather than rejecting a message accepted after the failed stop', async () => { - const queued = { clientMessageId: 'm', dispatchState: 'pending', handoverRecorded: true } - const session = { - journal: { - submissions: () => [queued], - pendingSubmissions: () => [queued], - snapshot: () => ({ items: [] }) - }, - child: null, - owesProviderChildWindDown: { generation: 'generation-1', fence: 1 } - } - const stopAgent = vi.fn(async () => undefined) - const finishOwedWindDown = vi.fn(async () => true) - const sweep = new StructuredAgentSessionIdleSweep({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a session fixture carrying only the journal and child facts the sweep reads. - sessions: Object.assign(new Map([[SESSION, session as never]]), { - lastActivityAt: () => 0, - touch: () => undefined - }), - serialize: (_id, task) => task(), - now: () => IDLE_MS + 1, - isDisposed: () => false, - deliveryActive: () => true, - childWork: () => undefined, - hasOpenDispatch: () => false, - providerHoldsDispatch: () => false, - stopAgent, - stopStartingAgent: stopAgent, - finishOwedWindDown, - closeConversation: vi.fn(async () => false), - // A failed step fails the test. - logger: { - warn: (_message, fields) => { - throw fields.error - }, - error: (_message, fields) => { - throw fields.error - } - } - }) - await sweep.tick() - expect(stopAgent).not.toHaveBeenCalled() - expect(finishOwedWindDown).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 19af6d048d45..3a35b4b138ae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -12,10 +12,10 @@ import { import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { createJournalOpenReadRefusals } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import { releaseLeaseOfEndedStructuredAgentSessionChild } from './structured-agent-session-child-close' import { abandonQueuedStructuredAgentSessionMessages, closeStructuredAgentSessionConversationUnderSerialize, - finishOwedStructuredAgentSessionWindDownUnderSerialize, stopStructuredAgentSessionAgentUnderSerialize, type StructuredAgentSessionCloseCause, type StructuredAgentSessionLifetimeContext, @@ -57,11 +57,13 @@ export function createStructuredAgentSessionConversationLifetime(host: { }) const stopAgent = (sessionId: string, ending: StructuredAgentSessionStopEnding) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, ending) - const finishOwedWindDown = (sessionId: string) => - finishOwedStructuredAgentSessionWindDownUnderSerialize(host.context(), sessionId) - const closeConversation = (sessionId: string): Promise => - closeStructuredAgentSessionConversationUnderSerialize( + const closeConversation = async (sessionId: string): Promise => { + // The handle carries the proof that releases a lease its child's wind-down could not. + if (await releaseLeaseOfEndedStructuredAgentSessionChild(host.context(), sessionId)) { + return false + } + return closeStructuredAgentSessionConversationUnderSerialize( { sessions, closeStatus: (id) => { @@ -72,6 +74,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { }, sessionId ) + } const idleSweep = new StructuredAgentSessionIdleSweep({ sessions, @@ -87,7 +90,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true, // The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop. stopAgent: (sessionId) => stopAgent(sessionId, { cause: 'evict', resting: true }), - finishOwedWindDown, // A host stop: the delivery loop waiting on this child writes the one error row and rejects // what is queued with it, both worded from the hostStopped fact. stopStartingAgent: (sessionId) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index 155837a01e16..1b0b9310b2be 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -307,9 +307,10 @@ describe('a Stop that names no turn', () => { expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) + // Bookkeeping after the proven exit: reported, never the Stop's failure. expect(log.entries).toContainEqual( expect.objectContaining({ - fields: expect.objectContaining({ scope: 'stop-child', sessionId: SESSION }) + fields: expect.objectContaining({ scope: 'exit-wind-down', sessionId: SESSION }) }) ) expect(await statusRows()).toEqual(['Cancellation requested.']) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 0414836f4a5e..3727040e3c0b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -26,10 +26,7 @@ import { structuredAgentSessionStartFailure, type StructuredAgentSessionStartFailureCause } from './structured-agent-session-failure-text' -import { - isStructuredAgentSessionPreviousExitUnverifiable, - type StructuredAgentSessionResumeOutcome -} from './structured-agent-session-agent-start' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' import type { StructuredAgentSessionChildEndCause, StructuredAgentSessionEndedChild, @@ -42,10 +39,6 @@ import { } from './structured-agent-session-start-failure-row' import { failedProviderChildStart } from './structured-agent-session-provider-child' import { handOverSubmission } from './structured-agent-session-turns' -import { - recordStructuredAgentSessionWindDownWait, - structuredAgentSessionWindDownWaitHolds -} from './structured-agent-session-wind-down-wait-row' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -189,24 +182,11 @@ export class StructuredAgentSessionDeliveryLoop { if (!oldest || (session.child && structuredAgentSessionCommandRunning(session.journal))) { return this.stop(sessionId) } - // Already waiting on a stop that could not prove its child gone: a new message retries it, and - // any other retry that lands wakes this loop itself, so the waiting row's own commit does not. - // Another operation's retry may have failed first, so the row is made sure of here too. - if (structuredAgentSessionWindDownWaitHolds(session)) { - await recordStructuredAgentSessionWindDownWait(session, sessionId, this.deps) - return this.stop(sessionId) - } const failedStart = startThatFailedWhileQueued(session, oldest) if (failedStart) { return this.fail(sessionId, failedStart) } const ready = await this.deps.ensureProviderChild(sessionId, oldest.clientMessageId) - if (!ready.ok && isStructuredAgentSessionPreviousExitUnverifiable(ready.refusal)) { - // The start retried that stop first and still could not prove the exit: the message waits, - // saying why, rather than being refused. The row is written once per unproven child. - await recordStructuredAgentSessionWindDownWait(session, sessionId, this.deps) - return this.stop(sessionId) - } if (!ready.ok) { return ready } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 2b9340859f42..1a7742d3e5ff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -8,6 +8,8 @@ import type { import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' import { settleStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' import { settleUnexpectedStructuredAgentSessionExit } from './structured-agent-session-unexpected-exit' +import { endClosedStructuredAgentSessionChild } from './structured-agent-session-child-close' +import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' export class StructuredAgentSessionEventRecovery { private readonly sinkFailures = new Set() @@ -22,11 +24,17 @@ export class StructuredAgentSessionEventRecovery { publishStatus?: (sessionId: string) => void serialize: (sessionId: string, task: () => Promise) => Promise now: () => number + /** Where a close the host asked for ends the child's record. */ + lifetime: () => StructuredAgentSessionLifetimeContext } ) {} private get exitContext() { - return { ...this.context, logger: this.context.deps.logger } + return { + ...this.context, + logger: this.context.deps.logger, + wakeDelivery: this.context.lifetime().wakeDelivery + } } recoverAfterSinkFailure(sessionId: string, error: unknown): void { @@ -72,12 +80,16 @@ export class StructuredAgentSessionEventRecovery { .finally(() => this.sinkFailures.delete(sessionId)) } - /** An exit is settled and shown; nothing restarts the child. The next send does, through the - * delivery loop, which also owns any message still queued. */ + /** Every child's exit ends its record here, expected or not. An exit is settled and shown; + * nothing restarts the child. The next send does, through the delivery loop, which also owns any + * message still queued. */ async handle(event: StructuredAgentSessionLifecycleEvent): Promise { if (event.type === 'started') { return settleStructuredAgentSessionProviderStarted(this.context, event) } + if (event.cause === 'requested-close') { + return endClosedStructuredAgentSessionChild(this.context.lifetime(), event) + } await settleUnexpectedStructuredAgentSessionExit(this.exitContext, event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts index f34caba3593d..12b5316ef6f7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts @@ -1,12 +1,9 @@ -// A bound on how long teardown may take, expressed as a wrapper around the eviction steps rather -// than a change to them. +// A bound on how long a caller waits on teardown, never on the teardown itself. // -// The step list is ordered and abort-on-failure for reasons that have nothing to do with time, and -// a deadline must not disturb either. Wrapping each step's `run` keeps the order, and a timeout -// surfaces as that step failing — which is exactly the behavior wanted here: the rest of the -// eviction aborts, the session stays indexed, and the child stays LOADED. A stuck app-server that -// is still holding a conversation is a better outcome than one killed out from under it; the next -// close retries. +// Two uses. A caller joining a child's close waits this long for the exit's proof and then answers +// `unverifiable`; the close keeps running, and a proof that lands later still ends the record. Each +// wind-down step after the exit is bounded the same way, so one hung step is reported and the rest +// still run. import type { StructuredAgentSessionEvictionStep } from './structured-agent-session-eviction' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index d12a6509c747..269ced39ac59 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -5,10 +5,6 @@ import { STRUCTURED_AGENT_SESSION_EVICTION_STEPS, type StructuredAgentSessionEvictionContext } from './structured-agent-session-eviction' -import { - AgentSessionAcquisitionRootExitObservedError, - AgentSessionPreSpawnError -} from './structured-agent-session-adapter' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' @@ -27,12 +23,6 @@ function context(): StructuredAgentSessionEvictionContext & { order: string[] } }), close: vi.fn(() => order.push('close')) } as unknown as StructuredAgentSessionEvictionContext['eventSink'], - adapter: { - closeSession: vi.fn(async () => { - order.push('closeSession') - return true - }) - } as unknown as StructuredAgentSessionEvictionContext['adapter'], acknowledgeRelease: vi.fn(() => { order.push('acknowledgeRelease') }), @@ -55,12 +45,11 @@ function runtimeState(): StructuredAgentSessionHostRuntimeState { } as never) } -describe('structured agent session eviction', () => { - it('stops the child before it lets the sink go, then acknowledges the release', async () => { +describe('the wind-down after a proven exit', () => { + it('drains what the child said before it lets the sink go, then acknowledges the release', async () => { const ctx = context() await evictStructuredAgentSession(ctx) expect(ctx.order).toEqual([ - 'closeSession', 'drained', 'settleWork', 'unbind', @@ -71,24 +60,8 @@ describe('structured agent session eviction', () => { ]) }) - it('uses disposal rather than handoff close when the chat is removed', async () => { - const ctx = context() - const closeSession = vi.fn(async () => { - throw new Error('resume cursor unavailable') - }) - const disposeSession = vi.fn(async () => true) - ctx.adapter = { ...ctx.adapter, closeSession, disposeSession } - - await evictStructuredAgentSession(ctx) - - expect(disposeSession).toHaveBeenCalledWith('session-1') - expect(closeSession).not.toHaveBeenCalled() - }) - - it('names every step, so a half-finished eviction says which one failed', () => { + it('names every step, so a failure says which one it was', () => { expect(STRUCTURED_AGENT_SESSION_EVICTION_STEPS.map((step) => step.name)).toEqual([ - 'snapshot-before-stop', - 'stop-provider-child', 'drain-published', 'settle-dead-generation', 'stop-publishing', @@ -99,94 +72,21 @@ describe('structured agent session eviction', () => { ]) }) - it('still stops the child when the pre-stop snapshot cannot drain the sink', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - try { - const ctx = context() - const snapshot = vi.fn() - ctx.beforeProviderChildStop = snapshot - // A journal write that never settles, then a healthy sink once the child is stopped. - vi.mocked(ctx.eventSink.drained).mockReturnValueOnce(new Promise(() => {})) - - const eviction = evictStructuredAgentSession(ctx) - await vi.advanceTimersByTimeAsync(1_000) - - expect(snapshot).toHaveBeenCalledOnce() - expect(ctx.adapter.closeSession).toHaveBeenCalledOnce() - await eviction - } finally { - vi.useRealTimers() - } - }) - - it('aborts after a failed drain barrier without unbinding or acknowledging the release', async () => { - const ctx = context() + // Bookkeeping for a process already gone: none of it may keep the child on record. + it('reports a failed step with its name and still runs every step after it', async () => { + const recorded = recordingStructuredAgentSessionLogger() + const ctx = { ...context(), logger: recorded.logger } ctx.eventSink.drained = vi.fn(async () => { ctx.order.push('drained') return { ok: false, error: new Error('append failed') } }) as unknown as StructuredAgentSessionEvictionContext['eventSink']['drained'] - - await expect(evictStructuredAgentSession(ctx)).rejects.toMatchObject({ - step: 'drain-published' + ctx.releaseLease = vi.fn(async () => { + ctx.order.push('releaseLease') + throw new Error('store unavailable') }) - expect(ctx.eventSink.unbind).not.toHaveBeenCalled() - expect(ctx.eventSink.close).not.toHaveBeenCalled() - expect(ctx.discardSink).not.toHaveBeenCalled() - expect(ctx.releaseLease).not.toHaveBeenCalled() - expect(ctx.acknowledgeRelease).not.toHaveBeenCalled() - expect(ctx.order).toEqual(['closeSession', 'drained']) - }) -}) -// Closing the codex child is not silent: the adapter emits its `ended` event and flushes coalesced -// text as it shuts down, and those rows are what clear the running-turn marker. If the sink is -// already closed the journal keeps claiming the agent is working, forever. -describe('rows the provider emits while closing', () => { - it('still reach the journal', async () => { - const state = runtimeState() - const sessionId = 'session-closing-rows' - const sink = state.eventSinkFor(sessionId) - const published: string[] = [] - sink.bind({ journal: {} as never, fence: 1, publish: () => published.push('final-flush') }) + await expect(evictStructuredAgentSession(ctx)).resolves.toBeUndefined() - await evictStructuredAgentSession({ - sessionId, - logger: recordingStructuredAgentSessionLogger().logger, - eventSink: sink, - adapter: { - closeSession: async () => { - // What codex-structured-session-close does on its way out. - sink.sink.publish() - return true - } - } as never, - discardSink: () => state.discardEventSink(sessionId), - releaseLease: async () => {}, - acknowledgeRelease: () => {} - }) - - expect(published).toEqual(['final-flush']) - }) -}) - -// `closeSession` returning false means the adapter could not prove the child exited and has kept -// the session indexed on purpose so a retry can reach it. -describe('a child that will not stop', () => { - it.each([ - new AgentSessionAcquisitionRootExitObservedError(new Error('root exited')), - new AgentSessionPreSpawnError(new Error('spawn failed')) - ])('continues eviction after an actionable provider verdict', async (error) => { - const ctx = context() - ctx.adapter.closeSession = vi.fn(async () => { - throw error - }) - const stopped = vi.fn() - ctx.onProviderChildStopped = stopped - - await evictStructuredAgentSession(ctx) - - // The host ends its child on the one reading of the verdict, not a second one of its own. - expect(stopped).toHaveBeenCalledWith({ rootGone: true }) expect(ctx.order).toEqual([ 'drained', 'settleWork', @@ -196,31 +96,11 @@ describe('a child that will not stop', () => { 'releaseLease', 'acknowledgeRelease' ]) - }) - - it('aborts without acknowledging the release, so the next stop is a real retry', async () => { - const ctx = context() - ctx.adapter.closeSession = vi.fn(async () => false) - - await expect(evictStructuredAgentSession(ctx)).rejects.toMatchObject({ - step: 'stop-provider-child' - }) - expect(ctx.acknowledgeRelease).not.toHaveBeenCalled() - expect(ctx.discardSink).not.toHaveBeenCalled() - expect(ctx.order).toEqual([]) - }) - - it('reports the failing step and leaves the sink usable for the retry', async () => { - const ctx = context() - ctx.adapter.closeSession = vi.fn(async () => { - throw new Error('child would not stop') - }) - - await expect(evictStructuredAgentSession(ctx)).rejects.toBeInstanceOf( - StructuredAgentSessionEvictionError - ) - expect(ctx.eventSink.close).not.toHaveBeenCalled() - expect(ctx.acknowledgeRelease).not.toHaveBeenCalled() + expect(recorded.entries.map((entry) => entry.fields.error)).toEqual([ + expect.objectContaining({ step: 'drain-published' }), + expect.objectContaining({ step: 'release-lease' }) + ]) + expect(recorded.entries[0]?.fields.error).toBeInstanceOf(StructuredAgentSessionEvictionError) }) }) @@ -235,8 +115,8 @@ describe('eviction against the real sink cache', () => { sessionId, logger: recordingStructuredAgentSessionLogger().logger, eventSink: state.eventSinkFor(sessionId), - adapter: { closeSession: async () => true } as never, discardSink: () => state.discardEventSink(sessionId), + settleWork: async () => {}, releaseLease: async () => {}, acknowledgeRelease: () => {} }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts index dcc31c451d2f..e0743e8fb028 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -1,51 +1,34 @@ -// Stopping one structured session's provider child and handing its lease back. +// What a host does once its provider child is gone: the bookkeeping that hands the session back. // // Teardown is a DATA list, not a method body, for the reason this file exists at all: the host // tracked which sessions were live in a map, and tore them down at three unrelated call sites // (app quit, handoff to a TUI, and error cleanup). Closing a chat was never wired to any of them, // so a provider child outlived the chat that owned it for the whole app session. // -// ORDER. The provider child stops FIRST. Closing it is not silent: the codex adapter emits its -// `ended` event and flushes coalesced text as part of shutting down, and those are the rows that -// clear the running-turn marker. Draining or closing the sink ahead of that drops them, which -// leaves the durable journal claiming the agent is still working — a worse outcome than the leak -// this teardown exists to fix. So: stop the child, drain what it emitted on its way out, then let -// the sink go. The one step ahead of the stop only reads, for quit's resume offer. +// ORDER. These run only after the child's exit is proven (`structured-agent-session-child-close`). +// Closing it is not silent: the codex adapter emits its `ended` event and flushes coalesced text as +// part of shutting down, and those are the rows that clear the running-turn marker. Draining or +// closing the sink ahead of that drops them, which leaves the durable journal claiming the agent is +// still working. So: drain what the child emitted on its way out, settle, then let the sink go. // -// FAILURE. A step that fails ABORTS the rest. `closeSession` returning false means the child's -// exit was not proven and the adapter has deliberately kept the session indexed so a retry can -// reach it; forgetting it anyway stranded the process forever and reported success. Leaving the -// session in place is what makes the next close a real retry instead of a no-op. +// FAILURE. Each step is bookkeeping for a process already gone, so a step that fails is reported +// and the rest still run: none of them may keep a dead child on record or gate the next send. -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import type { StructuredAgentSessionStopVerdict } from './structured-agent-session-host-types' import { withTimeout } from '../../../shared/promise-timeout-fallback' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionEvictionContext = { sessionId: string - hasProviderChild?: boolean eventSink: DeferredStructuredAgentSessionEventSink - adapter: StructuredAgentSessionAdapter logger: StructuredAgentSessionLogger /** Tells the adapter the released lease is done with, so it drops this child's route and index. * The conversation stays: stopping the agent never closes its journal. */ acknowledgeRelease: () => Promise | void /** Drops the cached sink so a later attach mints a fresh one. */ discardSink: () => void - /** Fires right before the stop, while the child's turn and background roster are still live. A - * throw is logged, never allowed to abort the stop. */ - beforeProviderChildStop?: () => void - /** Fires with the stop's verdict once `stopAgentSessionProviderRoot` read the root gone, so host - * bookkeeping stops claiming a child. */ - onProviderChildStopped?: (verdict: StructuredAgentSessionStopVerdict) => void - /** Whether this host still owes the child's wind-down. Distinct from `hasProviderChild`, which a - * proven exit retires mid-run: the two disagree for exactly the steps a retry has to repeat. */ - owesProviderChildWindDown?: boolean /** Settles work owned by the child after its final callbacks have drained. */ - settleWork?: () => Promise + settleWork: () => Promise /** Hands the lease back now that this host's child is proven gone. No-ops when the record is * not this host's to release. */ releaseLease: () => Promise @@ -59,44 +42,25 @@ export type StructuredAgentSessionEvictionStep = { run: (context: StructuredAgentSessionEvictionContext) => Promise | void } +/** Quit's resume offer: what the sidebar shows, read while the child is still running. Events the + * provider already delivered are part of what it showed at the stop. A throw is logged. */ +export async function snapshotBeforeStructuredAgentSessionStop( + context: Pick, + snapshot: () => void +): Promise { + await withTimeout(context.eventSink.drained(), SNAPSHOT_DRAIN_TIMEOUT_MS, null) + try { + snapshot() + } catch { + context.logger.warn('capturing a recovery witness before a stop failed', { + scope: 'recovery-witness', + sessionId: context.sessionId + }) + } +} + export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSessionEvictionStep[] = [ - { - // Quit's resume offer: what the sidebar shows, read while the child is still running. - name: 'snapshot-before-stop', - run: async (context) => { - if (context.hasProviderChild === false || !context.beforeProviderChildStop) { - return - } - // Events the provider already delivered are part of what the sidebar showed at the stop. - await withTimeout(context.eventSink.drained(), SNAPSHOT_DRAIN_TIMEOUT_MS, null) - try { - context.beforeProviderChildStop() - } catch { - context.logger.warn('capturing a recovery witness before a stop failed', { - scope: 'recovery-witness', - sessionId: context.sessionId - }) - } - } - }, - { - name: 'stop-provider-child', - run: async (context) => { - if (context.hasProviderChild === false) { - return - } - // An adapter with no close has nothing to stop; anything else must PROVE the exit. - const stop = context.adapter.disposeSession ?? context.adapter.closeSession - const rootGone = stop - ? await stopAgentSessionProviderRoot(() => stop.call(context.adapter, context.sessionId)) - : true - if (!rootGone) { - throw new Error('provider child exit was not proven') - } - context.onProviderChildStopped?.({ rootGone }) - } - }, { name: 'drain-published', run: async (context) => { @@ -106,11 +70,7 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe } } }, - { - name: 'settle-dead-generation', - run: (context) => - context.owesProviderChildWindDown === false ? undefined : context.settleWork?.() - }, + { name: 'settle-dead-generation', run: (context) => context.settleWork() }, { name: 'stop-publishing', run: (context) => context.eventSink.unbind() }, { name: 'close-sink', run: (context) => context.eventSink.close() }, // Why: the runtime caches one sink per session id and hands the SAME instance to the next @@ -118,10 +78,8 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe // accepts every provider event and publishes none. Attach's own failure path already pairs // these two; eviction has to as well. { name: 'discard-sink', run: (context) => context.discardSink() }, - // Why here and not last: the durable lease still names a process this host just stopped, and a - // record left claiming a live owner is one nothing can resume — the next send would find a - // session it may not acquire. Placed BEFORE the acknowledgement so a release that cannot be - // written aborts while the adapter still routes the session, which is what makes the retry real. + // The durable lease still names a process this host just proved gone; a start re-derives the + // release from that proof if this write fails. { name: 'release-lease', run: (context) => context.releaseLease() }, { name: 'acknowledge-release', run: (context) => context.acknowledgeRelease() } ] @@ -137,11 +95,8 @@ export class StructuredAgentSessionEvictionError extends Error { } } -/** - * Runs the eviction steps in order, stopping at the first failure. The step name travels with the - * error because the caller's only useful response is to retry, and a retry is only safe when the - * session is still indexed — which is exactly what aborting preserves. - */ +/** Runs every wind-down step in order. A failure is reported with the step that failed, and the + * rest still run. */ export async function evictStructuredAgentSession( context: StructuredAgentSessionEvictionContext, steps: readonly StructuredAgentSessionEvictionStep[] = STRUCTURED_AGENT_SESSION_EVICTION_STEPS @@ -150,7 +105,11 @@ export async function evictStructuredAgentSession( try { await step.run(context) } catch (error) { - throw new StructuredAgentSessionEvictionError(step.name, context.sessionId, error) + context.logger.warn('a wind-down step after the agent exited failed', { + scope: 'exit-wind-down', + sessionId: context.sessionId, + error: new StructuredAgentSessionEvictionError(step.name, context.sessionId, error) + }) } } } 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 60c20cb768d9..449c425fb69d 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 @@ -9,34 +9,26 @@ // reentrant, so every public entry point takes it once and calls these. import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { withTimeout } from '../../../shared/promise-timeout-fallback' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { - evictStructuredAgentSession, - STRUCTURED_AGENT_SESSION_EVICTION_STEPS, - type StructuredAgentSessionEvictionContext + snapshotBeforeStructuredAgentSessionStop, + StructuredAgentSessionEvictionError } from './structured-agent-session-eviction' -import { withStructuredAgentSessionEvictionDeadline } from './structured-agent-session-eviction-deadline' +import { joinStructuredAgentSessionChildClose } from './structured-agent-session-child-close' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession, - StructuredAgentSessionOwedWindDown, - StructuredAgentSessionProviderChildIdentity + StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import { - endProviderChild, - pendingProviderChildWindDown, - sameProviderChild, - structuredAgentSessionConversationFence -} from './structured-agent-session-provider-child' -import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' -import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' export type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-stop-event' import { recordStopEvent, + STOP_EVENT_DRAIN_TIMEOUT_MS, stopEndsWork, type StructuredAgentSessionStopEnding } from './structured-agent-session-host-stop-event' @@ -50,6 +42,8 @@ export type StructuredAgentSessionLifetimeContext = { publishStatus?: (sessionId: string) => void /** Hands the delivery loop what is queued; for a caller inside the session's serialize. */ wakeDelivery?: (sessionId: string) => void + /** The session's lane, which a close's late proof takes to end the child's record. */ + serialize: (sessionId: string, task: () => Promise) => Promise /** Quit-only snapshot taken immediately before the provider child is stopped. */ restartWitness?: { beforeStop: (sessionId: string) => void @@ -90,43 +84,12 @@ export async function abandonQueuedStructuredAgentSessionMessages( ) } -/** The wind-down this host owes for the session's child. A live child always owes one, whatever a - * previous childless eviction recorded: a remembered tombstone must never outrank the child in - * front of it. */ -function owedProviderChildWindDown( - session: StructuredAgentSessionHostSession -): StructuredAgentSessionProviderChildIdentity | undefined { - return session.child - ? { generation: session.child.generation, fence: session.child.fence } - : session.owesProviderChildWindDown -} - -/** The stop this pass owes. A retry continues the one already asked for, keeping where it was asked; - * any other stop is a new ask, even one with the same cause: a second close closes what came since. */ -function owedStop( - session: StructuredAgentSessionHostSession, - cause: StructuredAgentSessionStopCause, - retry: boolean -): StructuredAgentSessionOwedWindDown | undefined { - const owed = owedProviderChildWindDown(session) - if (!owed) { - return undefined - } - const asked = session.owesProviderChildWindDown - const continues = retry && asked !== undefined && sameProviderChild(asked, owed) - return { - generation: owed.generation, - fence: owed.fence, - cause, - requestedAt: continues ? asked.requestedAt : session.journal.cursor() - } -} - /** - * The agent goes to rest; the conversation stays. Runs the eviction steps under a deadline. A step - * that fails — or runs out of time — aborts the rest and leaves the wind-down owed, so the next - * stop is a real retry. `ending` is how the child's end is told: a user's Stop, the host stopping it - * for a cause (with its text), or an eviction the conversation's close follows. + * The agent goes to rest; the conversation stays. Begins the child's close, or joins the one + * already begun, and waits for the exit's proof as long as a caller may. Still unproven, it throws + * and the close keeps running: a later proof, or the next asker's attempt, ends the record. + * `ending` is how the child's end is told: a user's Stop, the host stopping it for a cause (with + * its text), or an eviction the conversation's close follows. The first stop's ending decides. */ export async function stopStructuredAgentSessionAgentUnderSerialize( context: StructuredAgentSessionLifetimeContext, @@ -134,141 +97,44 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( ending: StructuredAgentSessionStopEnding ): Promise { const session = context.sessions.get(sessionId) - if (!session) { + const child = session?.child + if (!session || !child) { return } - // Judged before the kill: a stop that ends nothing writes nothing. - const recorded = (await stopEndsWork(context, sessionId, session, ending)) - ? recordStopEvent(context, sessionId, session, ending) - : Promise.resolve() - // The obligation OUTLIVES the child. `child` is ended the instant the adapter proves the exit, - // so a step that aborts after that point would otherwise leave the retry reading "no child - // here" and skipping the settlement and the lease release it still owes. - const asked = 'recorded' in ending ? ending.recorded : ending.cause - // A retry finishes the stop that ended the child, so the child's end keeps that stop's cause. - const cause = session.child ? asked : (session.owesProviderChildWindDown?.cause ?? asked) - const owed = owedStop(session, cause, ending.retry === true) - session.owesProviderChildWindDown = owed - const stopping = session.child - const eviction: StructuredAgentSessionEvictionContext = { - sessionId, - // The retry must not re-stop a child the adapter already proved gone, so this stays honest. - hasProviderChild: stopping !== null, - owesProviderChildWindDown: owed !== undefined, - eventSink: context.runtimeState.eventSinkFor(sessionId), - adapter: context.deps.adapter, - logger: context.deps.logger, - ...(context.restartWitness - ? { beforeProviderChildStop: () => context.restartWitness?.beforeStop(sessionId) } - : {}), - // Host state must not disagree with the adapter for the steps in between. - onProviderChildStopped: (verdict) => { - if (stopping) { - endProviderChild(session, { - generation: stopping.generation, - fence: stopping.fence, - cause, - reason: ('reason' in ending ? ending.reason : undefined) ?? null, - duringStartup: stopping.phase === 'starting', - // A later retry that proves the exit still ends the child at the Stop it finishes. - ...(owed ? { endedAt: owed.requestedAt } : {}), - ...verdict - }) - } - context.restartWitness?.stopped(sessionId) - }, - acknowledgeRelease: () => context.deps.adapter.acknowledgeSessionRelease?.(sessionId), - discardSink: () => context.runtimeState.discardEventSink(sessionId), - settleWork: async () => { - // Folded before the fallback's end is built, so the end reads it (`turnEndAfterStop`). - await recorded - const fence = - owed?.fence ?? structuredAgentSessionConversationFence(context.deps.store, sessionId) - const settled = await settleStructuredAgentSessionDeadGeneration({ - journal: session.journal, - sessionId, - fence, - settlementId: `expected-close:${sessionId}:${fence}:${owed?.generation ?? 'unknown'}`, - pendingSubmissionReason: 'provider_closed_before_acknowledgement', - // Only a turn no adapter settled: one with no close, or whose settle threw. Whether it was - // a person's Stop is its event's to say (`turnEndAfterStop`). - verdict: { state: 'interrupted', completedAt: context.now() }, - showUnexpectedExitOutcome: false - }) - if (!settled.ok) { - context.deps.logger.warn("settling a closed agent's work failed", { - scope: 'close-settlement', - sessionId, - error: settled.error - }) - // Without the cause the log names the step and nothing else. - throw new Error('dead generation work settlement failed', { cause: settled.error }) - } - }, - releaseLease: async () => { - if (owed) { - await releaseStoredStructuredAgentSessionOwner({ - store: context.deps.store, - sessionId, - hasProviderChild: true, - expectedFence: owed.fence, - now: context.now() - }) - } - session.owesProviderChildWindDown = undefined - // Whatever ended the child, the row belongs to the conversation: it shows not-running, and - // only the conversation's close forgets it. - context.publishStatus?.(sessionId) - // The stop's own end hands over what waited on it, whichever caller's retry landed. - context.wakeDelivery?.(sessionId) + if (!child.close) { + // Judged before the kill: a stop that ends nothing writes nothing. + const recorded = (await stopEndsWork(context, sessionId, session, ending)) + ? recordStopEvent(context, sessionId, session, ending) + : Promise.resolve() + child.close = { + cause: 'recorded' in ending ? ending.recorded : ending.cause, + reason: ('reason' in ending ? ending.reason : undefined) ?? null, + recorded } + // The event lands before the rows the kill makes the child write, bounded so a slow journal + // never holds the kill. + await withTimeout(recorded, STOP_EVENT_DRAIN_TIMEOUT_MS, undefined) } - try { - await evictStructuredAgentSession( - eviction, - withStructuredAgentSessionEvictionDeadline(STRUCTURED_AGENT_SESSION_EVICTION_STEPS) + if (context.restartWitness) { + await snapshotBeforeStructuredAgentSessionStop( + { + sessionId, + eventSink: context.runtimeState.eventSinkFor(sessionId), + logger: context.deps.logger + }, + () => context.restartWitness?.beforeStop(sessionId) ) - } catch (error) { - if (session.owesProviderChildWindDown === owed && owed) { - session.owesProviderChildWindDown = { ...owed, failedAt: session.journal.cursor() } - } - throw error } -} - -/** - * Retries the wind-down an earlier stop left owed, with that stop's own cause: a child it could - * not prove gone takes no input, so nothing may write to it or start beside it until this lands. - * One pass of the stop, bounded by its own step deadline (10 s): a shorter bound would cut a - * supervised Claude's exit proof (up to about 7 s) short. Resolves whether nothing is owed now; a - * failure is reported, never thrown, and leaves the exit unverifiable, never exited. Landing, the - * stop itself hands over what waited on it. - */ -export async function finishOwedStructuredAgentSessionWindDownUnderSerialize( - context: StructuredAgentSessionLifetimeContext, - sessionId: string -): Promise { - const session = context.sessions.get(sessionId) - const owed = session && pendingProviderChildWindDown(session) - if (!owed) { - return true - } - try { - await stopStructuredAgentSessionAgentUnderSerialize( - context, + if ( + (await joinStructuredAgentSessionChildClose(context, sessionId, child, child.close)) !== + 'exited' + ) { + throw new StructuredAgentSessionEvictionError( + 'stop-provider-child', sessionId, - owed.cause === 'user-stop' - ? { recorded: 'user-stop', retry: true } - : { cause: owed.cause, retry: true } + new Error('provider child exit was not proven') ) - } catch (error) { - context.deps.logger.warn('retrying an unfinished agent stop failed', { - scope: 'owed-stop-retry', - sessionId, - error - }) } - return context.sessions.get(sessionId)?.owesProviderChildWindDown === undefined } /** A close's cause: the user closing this chat, or the host evicting it (quit, idle, teardown). */ @@ -277,13 +143,13 @@ export type StructuredAgentSessionCloseCause = Extract< 'user-close' | 'evict' > -/** Whether the conversation's handle is only a cache now: no child, no wind-down owed, and nothing - * queued or waiting on the provider. */ +/** Whether the conversation's handle is only a cache now: no child, and nothing queued or waiting + * on the provider. */ export function structuredAgentSessionConversationClosable( session: StructuredAgentSessionHostSession ): boolean { return ( - owedProviderChildWindDown(session) === undefined && + session.child === null && !session.journal.submissions().some(isQueuedAgentJournalSubmission) && session.journal.pendingSubmissions().length === 0 ) @@ -312,9 +178,7 @@ export async function closeStructuredAgentSessionConversationUnderSerialize( return true } -/** Stops every provider child owned by this host while keeping failed evictions reachable. A - * session whose child is already stopped but whose wind-down aborted is still in scope — that is - * the retry. */ +/** Stops every provider child owned by this host while keeping failed evictions reachable. */ export async function evictOwnedStructuredAgentSessions( context: StructuredAgentSessionLifetimeContext & { serialize: (sessionId: string, task: () => Promise) => Promise @@ -322,7 +186,7 @@ export async function evictOwnedStructuredAgentSessions( retainOnFailure: Set ): Promise { const ownedSessionIds = [...context.sessions] - .filter(([, session]) => owedProviderChildWindDown(session) !== undefined) + .filter(([, session]) => session.child !== null) .map(([sessionId]) => sessionId) // Retained up front and cleared only once a stop settles: the quit phase is bounded, and a // timeout leaves these still running. Closing their journals underneath them is the one outcome 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 757a76a90590..2430884f6a1b 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 @@ -11,7 +11,7 @@ import { structuredAgentSessionConversationFence } from './structured-agent-sess /** 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. */ -export type StructuredAgentSessionStopEnding = ( +export type StructuredAgentSessionStopEnding = | { recorded: 'user-stop' } | { cause: Exclude @@ -21,19 +21,16 @@ export type StructuredAgentSessionStopEnding = ( * no work its event records. */ resting?: true } -) & { - /** The retry of a stop already owed, set only by that retry: its event, if any, is written. */ - retry?: true -} -/** How long a host stop waits for the session's sink before it judges whether the stop ends work. */ -const STOP_EVENT_DRAIN_TIMEOUT_MS = 1_000 +/** How long a host stop waits for the session's sink before it judges whether the stop ends work, + * and for its event's write before the kill. */ +export const STOP_EVENT_DRAIN_TIMEOUT_MS = 1_000 /** * Whether this stop ends work its event must record: a running turn or an unanswered send, a start's * own included, read once the sink drained what the provider already said. A start that carries - * no send ends nothing. A person's Stop wrote its own event, and quit, the idle sweep's rest and a - * retry of a stop already owed write none. + * no send ends nothing. A person's Stop wrote its own event, and quit and the idle sweep's rest + * write none; a stop that joins a close already begun writes none either. */ export async function stopEndsWork( context: StructuredAgentSessionLifetimeContext, @@ -42,7 +39,7 @@ export async function stopEndsWork( ending: StructuredAgentSessionStopEnding ): Promise { const { child, journal } = session - if ('recorded' in ending || ending.quit || ending.resting || ending.retry || !child) { + if ('recorded' in ending || ending.quit || ending.resting || !child) { return false } // A failed drain has nothing more to deliver, so the journal's read as it stands holds. One @@ -70,8 +67,8 @@ export async function stopEndsWork( ) } -/** Writes this stop's event (`JournalStopEvent`). Issued before the kill and never awaited by it: - * bookkeeping, reported on failure. */ +/** Writes this stop's event (`JournalStopEvent`). Issued before the kill, which waits for it only + * `STOP_EVENT_DRAIN_TIMEOUT_MS`: bookkeeping, reported on failure. */ export function recordStopEvent( context: StructuredAgentSessionLifetimeContext, sessionId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 6552f8a26016..068a047ffab4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -38,13 +38,16 @@ export type StructuredAgentSessionProviderChildIdentity = { readonly fence: number } -/** A wind-down still owed, with the stop that owes it: a retry finishes that stop. */ -export type StructuredAgentSessionOwedWindDown = StructuredAgentSessionProviderChildIdentity & { +/** The close a stop began for its child. It lives on the child and ends with it: once begun, the + * child takes no input again, and every later stop, start or provider write joins it. */ +export type StructuredAgentSessionChildClose = { + /** The first stop's, which the child's end keeps however many asks join it. */ readonly cause: StructuredAgentSessionStopCause - /** Where the journal stood when the stop was asked for; the child's end is ordered there. */ - readonly requestedAt: AgentJournalCursor - /** Where it stood once the newest pass failed: a message accepted by then waited through a retry. */ - readonly failedAt?: AgentJournalCursor + readonly reason: string | null + /** The Stop event that stop wrote, folded before the work it ends is settled. */ + readonly recorded: Promise + /** The adapter's close while it runs; cleared when it ends unproven, so the next ask retries. */ + attempt?: Promise } /** The provider process behind a conversation. Written only in @@ -56,6 +59,7 @@ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProvider /** The queued message whose delivery started this child, fixed when the start is made; absent * for any other start. In memory only: it tells a restart offer its own start from another. */ readonly startedFor?: string + close?: StructuredAgentSessionChildClose } /** What ending a child established about its provider root. A stop's comes only from @@ -78,8 +82,7 @@ export type StructuredAgentSessionEndedChild = StructuredAgentSessionProviderChi duringStartup: boolean startedFor?: string /** Where the conversation's journal stood when the child ended, to order the end against a - * message's acceptance. A stop's end stands where it was asked for: a message accepted while - * retries proved the exit waited on it, and came after it. */ + * message's acceptance. */ endedAt: AgentJournalCursor } @@ -92,10 +95,6 @@ export type StructuredAgentSessionHostSession = { * has none — so it may not be evicted to free a child, nor have its lease released as an * observed exit. */ child: StructuredAgentSessionProviderChild | null - /** The wind-down this host still owes for a child it started: settling that generation's work - * and handing the lease back. Outlives `child`, which ends the moment the adapter proves the - * exit — an eviction that aborts after that point must still finish it on the next close. */ - owesProviderChildWindDown?: StructuredAgentSessionOwedWindDown lastEndedChild?: StructuredAgentSessionEndedChild } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 44a576e2f781..d98b338abfde 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -158,7 +158,8 @@ export class StructuredAgentSessionHost { ), publishStatus: this.clientDelivery.publishStatusAndSettlement, serialize: (sessionId, task) => this.tasks.trackAttach(this.serialize(sessionId, task)), - now: () => this.now() + now: () => this.now(), + lifetime: () => this.lifetimeContext() }) this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { ...structuredAgentSessionRestartResumeSurfaces(this, this.now), @@ -193,7 +194,8 @@ export class StructuredAgentSessionHost { sessions: this.sessions, now: () => this.now(), publishStatus: this.clientDelivery.publishStatus, - wakeDelivery: (sessionId: string) => this.conversationDelivery.loop.wake(sessionId) + wakeDelivery: (sessionId: string) => this.conversationDelivery.loop.wake(sessionId), + serialize: this.serialize } satisfies StructuredAgentSessionLifetimeContext } @@ -279,11 +281,8 @@ export class StructuredAgentSessionHost { openConversation: this.conversationDelivery.open, ensureAgent: (sessionId) => agentStart.ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), - finishOwedStop: (sessionId) => - agentStart.finishOwedStructuredAgentSessionStopForProviderWrite( - this.attachContext(), - sessionId - ), + joinChildClose: (sessionId) => + agentStart.joinClosingStructuredAgentSessionChild(this.attachContext(), sessionId), wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), stopAgent: (sessionId, ending) => this.lifetime.stopAgent(sessionId, ending), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 7823f65b5355..9cb6dbab4ef2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -319,7 +319,6 @@ describe('the idle sweep with no child running (P2-22 ii)', () => { providerHoldsDispatch: () => false, stopAgent, stopStartingAgent: stopAgent, - finishOwedWindDown: vi.fn(async () => true), closeConversation, // A failed step fails the test. logger: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 96a5f59cf845..1b67f259ed95 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -15,7 +15,6 @@ import type { AgentJournalRenderItem } from '../../../shared/agent-session-journ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' -import { pendingProviderChildWindDown } from './structured-agent-session-provider-child' export const STRUCTURED_AGENT_SESSION_IDLE_SWEEP_INTERVAL_MS = 5 * 60_000 export const STRUCTURED_AGENT_SESSION_IDLE_MS = 30 * 60_000 @@ -37,8 +36,6 @@ export type StructuredAgentSessionIdleSweepDeps = { providerHoldsDispatch: (sessionId: string) => boolean /** Each of these runs inside the session's serialize and never takes it again. */ stopAgent: (sessionId: string) => Promise - /** Retries a stop that did not finish; landing, it hands over what waited on it. */ - finishOwedWindDown: (sessionId: string) => Promise stopStartingAgent: (sessionId: string) => Promise closeConversation: (sessionId: string) => Promise logger: StructuredAgentSessionLogger @@ -111,12 +108,6 @@ export class StructuredAgentSessionIdleSweep { if (!session || this.deps.isDisposed()) { return } - // A stop that did not finish: retry it now, before the idle test, so the rows its settlement - // wrote cannot push the retry out. A running delivery step retries it itself. - if (pendingProviderChildWindDown(session) && !this.deps.deliveryActive(sessionId)) { - await this.deps.finishOwedWindDown(sessionId) - return - } // Owed work is activity, read every tick, so the agent gets a full window once it ends: a child // can read done before the lead's wake-up turn writes anything. if (session.child && session.child.phase !== 'starting' && this.owesWork(sessionId, session)) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 6a66836fc8a6..68ff515f1272 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -33,9 +33,9 @@ export type StructuredAgentSessionMutationContext = { openConversation: (sessionId: string) => Promise /** Gives the session a provider child; inside the caller's serialize. */ ensureAgent: (sessionId: string) => Promise - /** Finishes a stop an earlier attempt left owed, for an operation that starts no child; inside - * the caller's serialize. */ - finishOwedStop: (sessionId: string) => Promise + /** Joins a close a stop began on the session's child, for an operation that starts no child; + * inside the caller's serialize. */ + joinChildClose: (sessionId: string) => Promise /** A message was accepted: the session's delivery loop hands it over. */ wakeDelivery: (sessionId: string) => void /** Stops the session's provider child, keeping its conversation; inside the caller's serialize. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts index 5785cfc53e19..2b84d4cfee23 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts @@ -5,13 +5,11 @@ // an exit, a failed re-attach, a Stop and an eviction. Each is matched on the child's generation // and fence, so an ending that arrives late for an older child cannot end a newer one. -import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionEndedChild, StructuredAgentSessionHostSession, - StructuredAgentSessionOwedWindDown, StructuredAgentSessionProviderChild, StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' @@ -48,12 +46,9 @@ export function markProviderChildStarted( return child !== null } -/** `endedAt` is a stop's ask; an exit ends where the journal stands. */ export function endProviderChild( session: ChildBearer, - ended: Omit & { - endedAt?: AgentJournalCursor - } + ended: Omit ): boolean { const child = matchingChild(session, ended) if (!child) { @@ -63,7 +58,7 @@ export function endProviderChild( session.lastEndedChild = { ...ended, ...(child.startedFor === undefined ? {} : { startedFor: child.startedFor }), - endedAt: ended.endedAt ?? session.journal.cursor() + endedAt: session.journal.cursor() } return true } @@ -77,15 +72,6 @@ export function failedProviderChildStart( return !session.child && ended?.duringStartup && ended.cause !== 'user-stop' ? ended : null } -/** The owed wind-down an operation reaching the provider finishes first. One owed for another child - * never outranks the child in front of it, which that child's own stop finishes. */ -export function pendingProviderChildWindDown( - session: Pick -): StructuredAgentSessionOwedWindDown | undefined { - const { child, owesProviderChildWindDown: owed } = session - return owed && (!child || sameProviderChild(child, owed)) ? owed : undefined -} - export function sameProviderChild( a: StructuredAgentSessionProviderChildIdentity, b: StructuredAgentSessionProviderChildIdentity diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index 7c6511dd2f21..f216b6ac8801 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -84,11 +84,12 @@ export function openForWrite( } /** For an operation the running child performs, which starts none: the conversation, then any - * stop an earlier attempt left owed, so it never reaches a child that takes no input. */ + * close a stop began on that child, which it joins, so it never reaches a child that takes no + * input. */ export function openForProviderWrite( context: Pick< StructuredAgentSessionMutationContext, - 'openConversation' | 'finishOwedStop' | 'deps' + 'openConversation' | 'joinChildClose' | 'deps' >, envelope: AgentSessionMutationEnvelope ): () => Promise { @@ -98,7 +99,7 @@ export function openForProviderWrite( envelope, context.deps.logger ) - return opened.ok ? context.finishOwedStop(envelope.sessionId) : opened + return opened.ok ? context.joinChildClose(envelope.sessionId) : opened } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index dd0acf82b371..f220d5a0e489 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -286,9 +286,9 @@ describe('every Stop entry writes its event, with its reason, before it ends the expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) }) - // The idle sweep finishes a stop whose exit was unproven: the same stop, so its event stands alone. + // A later stop joins a close whose exit was unproven: the same close, so its event stands alone. it.each([['user-close' as const], ['evict' as const]])( - 'writes one event for a close (%s) whose exit was unproven, and none for its retry', + 'writes one event for a close (%s) whose exit was unproven, and none for a stop that joins it', async (cause) => { rig = await createQueuedMessageTestRig({ idleSweep: MANUAL_IDLE_SWEEP }) await runningTurn() @@ -296,13 +296,14 @@ describe('every Stop entry writes its event, with its reason, before it ends the const session = () => rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)! await rig.host.close(HOST_TEST_SESSION, cause).catch(() => undefined) - expect(session().child).not.toBeNull() - expect(session().owesProviderChildWindDown).toMatchObject({ cause }) + expect(session().child?.close).toMatchObject({ cause }) expect(stopEvents()).toEqual([{ reason: cause, turnId: 'turn-1', at: expect.any(Number) }]) - await idleSweep().tick() + await rig.host['tasks'].serialize(HOST_TEST_SESSION, () => + rig.host['lifetime'].stopAgent(HOST_TEST_SESSION, { cause: 'host-stop' }) + ) - expect(session().owesProviderChildWindDown).toBeUndefined() + expect(session().child).toBeNull() expect(stopEvents().map((event) => event.reason)).toEqual([cause]) expect(session().lastEndedChild?.cause).toBe(cause) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 1f8fc80c863d..2976bf86a0a8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -337,7 +337,7 @@ describe('a chat that closes', () => { expect(closeSession).toHaveBeenCalledOnce() }) - it('settles and releases on the retry when a step after the child stopped aborts', async () => { + it('reports a failed drain after the child stopped, and still settles and releases', async () => { await attach() dispatch.mockResolvedValueOnce({ state: 'admitted' }) const body = hostTestMessage('pending across an aborted eviction') @@ -351,12 +351,9 @@ describe('a chat that closes', () => { failEvictionDrain() const settled = captureSettledSubmissions() - await expect(host.close(SESSION, 'evict')).rejects.toMatchObject({ step: 'drain-published' }) - // The child is proven gone, but the wind-down it owes is not done: nothing settled, no release. - expect(session!.child).toBeNull() - expect(store.getRecord(SESSION)?.lease.claimStatus).not.toBe('released') - + // The child is proven gone: the failed drain is bookkeeping, reported, and the rest still runs. await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() + expect(session!.child).toBeNull() expect(closeSession).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', @@ -645,7 +642,7 @@ describe('an unexpected provider exit', () => { unsubscribe() }) - it('keeps a requested close out of recovery', async () => { + it('ends the record of a requested close as a close, never as a crash to recover from', async () => { await attach() const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -659,7 +656,9 @@ describe('an unexpected provider exit', () => { }) expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + expect(host['sessions'].get(SESSION)?.child).toBeNull() + expect(host['sessions'].get(SESSION)?.lastEndedChild).not.toHaveProperty('failure') + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) it('recovers after a failed lifecycle barrier and dispatches a distinct next message', async () => { @@ -812,19 +811,16 @@ describe('an unexpected provider exit', () => { }) }) -describe('a quit over an eviction that never got its retry', () => { - // Nothing calls `close` a second time when the user quits instead of reopening the chat, so the - // quit sweep is the last thing that can hand the lease back — and it only reaches the session if - // it still counts a stopped child's unfinished wind-down as owed. - it('finishes the wind-down the aborted close left behind', async () => { +describe('a quit after a close whose drain failed', () => { + // The close's own wind-down hands the lease back past a failed drain, so quit finds nothing owed. + it('has nothing of that child left to finish', async () => { await attach() - await sendPending('pending across an abandoned eviction') + await sendPending('pending across a failed drain') const settled = captureSettledSubmissions() failEvictionDrain() - await expect(host.close(SESSION, 'evict')).rejects.toMatchObject({ step: 'drain-published' }) - expect(host['sessions'].get(SESSION)?.child).toBeNull() - expect(store.getRecord(SESSION)?.lease.claimStatus).not.toBe('released') + await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() + expect(host['sessions'].get(SESSION)?.child ?? null).toBeNull() await host.flushAllStreamedEvents() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 48500afa12f3..8051dd4fd1c8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -79,9 +79,9 @@ export type StructuredAgentSessionStopWindDown = { waitsForProvider: boolean; st /** * A session-ending Stop's second step, queued behind its first in the same tick so nothing sent - * meanwhile reaches the child it ends. The Stop has answered: a failure here is reported. The next - * operation that reaches the agent retries the wind-down it leaves owed, and so does the idle - * sweep's next tick. + * meanwhile reaches the child it ends. The Stop has answered: a failure here is reported. A close + * it could not prove keeps the child on record, and the next operation that reaches the agent + * joins that close. */ export async function endStoppedStructuredAgentSession( ctx: Pick, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 419f7005c40c..8db92939152e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -36,6 +36,8 @@ export type StructuredAgentSessionUnexpectedExitContext< flushLifecycle: (sessionId: string) => Promise publishFence: (sessionId: string, session: TSession) => void publishStatus?: (sessionId: string) => void + /** The delivery loop hands over whatever is queued once the child is off the record. */ + wakeDelivery?: (sessionId: string) => void serialize: (sessionId: string, task: () => Promise) => Promise now: () => number logger: StructuredAgentSessionLogger @@ -143,6 +145,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< if (released) { context.publishFence(unexpectedEvent.sessionId, session) } + context.wakeDelivery?.(unexpectedEvent.sessionId) } } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts deleted file mode 100644 index 5f1d03a7ead3..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts +++ /dev/null @@ -1,83 +0,0 @@ -// The row a message waiting on an unfinished stop leaves in the chat: why it has not gone out. - -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { - agentSessionFailureWords, - type AgentSessionFailureWordsContext -} from '../../../shared/agent-session-failure-words' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemIdentity -} from '../../../shared/agent-session-journal-types' -import type { - StructuredAgentSessionHostSession, - StructuredAgentSessionProviderChildIdentity -} from './structured-agent-session-host-types' -import { pendingProviderChildWindDown } from './structured-agent-session-provider-child' - -/** Keyed by the child the stop could not prove gone, so every send that waits on it shares a row. */ -export function structuredAgentSessionWindDownWaitIdentity( - owed: StructuredAgentSessionProviderChildIdentity -): AgentJournalItemIdentity { - return { - provider: 'orca', - clientMessageId: `wind-down-wait:${owed.fence}:${owed.generation ?? 'unknown'}` - } -} - -type WaitingSession = Pick< - StructuredAgentSessionHostSession, - 'journal' | 'child' | 'owesProviderChildWindDown' -> - -function windDownWaitRow( - session: WaitingSession, - owed: StructuredAgentSessionProviderChildIdentity -) { - const itemId = agentJournalItemKey(structuredAgentSessionWindDownWaitIdentity(owed)) - return session.journal.snapshot().items.find((item) => item.itemId === itemId) -} - -/** Every queued message already waited through a retry of the owed stop: it was accepted before the - * newest pass failed. Only a new message retries again, and a retry that lands wakes the loop - * itself, so no other commit, each of which wakes the delivery loop, retries. */ -export function structuredAgentSessionWindDownWaitHolds(session: WaitingSession): boolean { - const failedAt = pendingProviderChildWindDown(session)?.failedAt - if (!failedAt || failedAt.epoch !== session.journal.cursor().epoch) { - return false - } - return session.journal - .submissions() - .every( - (submission) => - !isQueuedAgentJournalSubmission(submission) || - (submission.acceptedSequence ?? 0) <= failedAt.sequence - ) -} - -/** Written once per child, so every message that waits on it shares the row. */ -export async function recordStructuredAgentSessionWindDownWait( - session: WaitingSession, - sessionId: string, - input: { - conversationFence: (sessionId: string) => number - failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext - } -): Promise { - const owed = pendingProviderChildWindDown(session) - if (!owed || windDownWaitRow(session, owed)) { - return - } - const identity = structuredAgentSessionWindDownWaitIdentity(owed) - const words = agentSessionFailureWords(agentSessionFailureFact('previousExitUnverifiable'), { - ...input.failureTextContext(sessionId), - surface: 'row' - }) - await session.journal.appendItem( - identity, - { kind: 'status', tone: 'warning', ...words }, - { fence: input.conversationFence(sessionId), turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) -} From 103598fd637b2916419ab4d455b4f514fea04e76 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:31:23 -0700 Subject: [PATCH 003/269] fix(native-chat): a message refused because the old process's exit is unverifiable says so, and to send again The start failure for a refusal with reason `previousExitUnverifiable` reads "Orca couldn't confirm Claude's previous process ended. Send your message to try again." instead of "Claude couldn't restart." The status-row kind and the refusal reason stay in the shared lists for rows and hosts that still carry them; the catalogs keep one sentence for both. --- src/renderer/src/i18n/en-runtime-required.json | 2 +- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- src/shared/agent-session-failure-copy.ts | 3 +-- src/shared/agent-session-failure-words.ts | 7 +++++++ src/shared/agent-session-failure.ts | 2 +- src/shared/agent-session-refusal-details.ts | 4 ++-- 11 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 72118c9ab68d..2ce3ec2b2420 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2759,7 +2759,7 @@ "notDelivered": "This message was not delivered.", "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.", "notSignedIn": "{{agent}} is not signed in for the selected account.", - "previousExitUnverifiable": "Orca couldn't confirm {{agent}}'s previous process ended. Messages wait to be sent until Orca confirms it has ended.", + "previousExitUnverifiable": "Orca couldn't confirm {{agent}}'s previous process ended.", "providerExitedRejection": "{{agent}} stopped before this message was sent.", "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", "providerRateLimited": "{{agent}} is rate-limited and retrying.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2f29e560803a..9d0a16114491 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17561,7 +17561,7 @@ "providerRateLimited": "{{agent}} is rate-limited and retrying.", "providerRetrying": "{{agent}} hit a temporary problem and is retrying.", "providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.", - "previousExitUnverifiable": "Orca couldn't confirm {{agent}}'s previous process ended. Messages wait to be sent until Orca confirms it has ended." + "previousExitUnverifiable": "Orca couldn't confirm {{agent}}'s previous process ended." }, "writeNotice": { "notDoneReadHistory": "This chat's history couldn't be loaded.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 45afe6f04a07..95d780a4ac47 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14999,7 +14999,7 @@ "providerRateLimited": "{{agent}} alcanzó un límite de solicitudes y está reintentando.", "providerRetrying": "{{agent}} tuvo un problema temporal y está reintentando.", "providerRetryingQuoted": "{{agent}} está reintentando: {{detail}}.", - "previousExitUnverifiable": "Orca no pudo confirmar que el proceso anterior de {{agent}} terminó. Los mensajes esperan para enviarse hasta que Orca confirme que terminó." + "previousExitUnverifiable": "Orca no pudo confirmar que el proceso anterior de {{agent}} terminó." }, "writeNotice": { "notDoneReadHistory": "No se pudo cargar el historial de este chat.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 85a1b64c154a..2d1a996c67db 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17465,7 +17465,7 @@ "providerRateLimited": "{{agent}} est limité en débit et réessaie.", "providerRetrying": "{{agent}} a rencontré un problème temporaire et réessaie.", "providerRetryingQuoted": "{{agent}} réessaie : {{detail}}.", - "previousExitUnverifiable": "Orca n'a pas pu confirmer que le processus précédent de {{agent}} s'est arrêté. Les messages attendent d'être envoyés jusqu'à ce qu'Orca confirme son arrêt." + "previousExitUnverifiable": "Orca n'a pas pu confirmer que le processus précédent de {{agent}} s'est arrêté." }, "writeNotice": { "notDoneReadHistory": "L'historique de ce chat n'a pas pu être chargé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 0bed9bab4225..a027e5e34e7d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17401,7 +17401,7 @@ "providerRateLimited": "{{agent}} はレート制限を受けているため、再試行しています。", "providerRetrying": "{{agent}} で一時的な問題が発生したため、再試行しています。", "providerRetryingQuoted": "{{agent}} は再試行しています: {{detail}}。", - "previousExitUnverifiable": "Orca は {{agent}} の前のプロセスが終了したことを確認できませんでした。Orca が終了を確認するまで、メッセージは送信を待機します。" + "previousExitUnverifiable": "Orca は {{agent}} の前のプロセスが終了したことを確認できませんでした。" }, "writeNotice": { "notDoneReadHistory": "このチャットの履歴を読み込めませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a503c3347d81..7c01fde23971 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17401,7 +17401,7 @@ "providerRateLimited": "{{agent}}이(가) 속도 제한에 걸려 다시 시도하고 있습니다.", "providerRetrying": "{{agent}}에 일시적인 문제가 발생하여 다시 시도하고 있습니다.", "providerRetryingQuoted": "{{agent}}이(가) 다시 시도하고 있습니다: {{detail}}.", - "previousExitUnverifiable": "Orca가 {{agent}}의 이전 프로세스가 종료되었는지 확인하지 못했습니다. Orca가 종료를 확인할 때까지 메시지는 전송을 기다립니다." + "previousExitUnverifiable": "Orca가 {{agent}}의 이전 프로세스가 종료되었는지 확인하지 못했습니다." }, "writeNotice": { "notDoneReadHistory": "이 채팅의 기록을 불러오지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 63fc2b87de52..bfb7bd46911c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17366,7 +17366,7 @@ "providerRateLimited": "{{agent}} 已被限流,正在重试。", "providerRetrying": "{{agent}} 遇到临时问题,正在重试。", "providerRetryingQuoted": "{{agent}} 正在重试:{{detail}}。", - "previousExitUnverifiable": "Orca 无法确认 {{agent}} 的上一个进程已结束。在 Orca 确认其已结束之前,消息会等待发送。" + "previousExitUnverifiable": "Orca 无法确认 {{agent}} 的上一个进程已结束。" }, "writeNotice": { "notDoneReadHistory": "无法加载此聊天的历史记录。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index e6eeab282229..04fa33c4b2bb 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -81,8 +81,7 @@ export const AGENT_SESSION_FAILURE_COPY = { providerRateLimited: '{{agent}} is rate-limited and retrying.', providerRetrying: '{{agent}} hit a temporary problem and is retrying.', providerRetryingQuoted: '{{agent}} is retrying: {{detail}}.', - previousExitUnverifiable: - "Orca couldn't confirm {{agent}}'s previous process ended. Messages wait to be sent until Orca confirms it has ended." + previousExitUnverifiable: "Orca couldn't confirm {{agent}}'s previous process ended." } as const export type AgentSessionFailureCopyId = keyof typeof AGENT_SESSION_FAILURE_COPY diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index f64ba5cb3352..1c9758d81820 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -145,6 +145,13 @@ function couldNot(verb: 'couldNotStart' | 'couldNotRestart'): Sentence { if (fact.refusal?.details?.reason === 'claimConflicted') { return joinSentences([failed, say('terminalAgentHoldsChat'), say('quitTerminalAgent')]) } + // The previous process may still run, so nothing started: that, never that it exited. + if (fact.refusal?.details?.reason === 'previousExitUnverifiable') { + return joinSentences([ + say('previousExitUnverifiable', agent(say, context)), + ...startRetry(say, context) + ]) + } const code = fact.refusal?.code return joinSentences( code && !START_REFUSAL_RESUMABLE[code] diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index b08c5250a150..a3c97850a1ea 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -48,7 +48,7 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'hostStopped', /** The provider is retrying a request its API refused; not a failure yet. */ 'providerRetrying', - /** A message waits on a child a Stop could not prove gone: its exit is unverifiable. */ + /** A child a Stop could not prove gone: its exit is unverifiable. Kept for rows hosts wrote. */ 'previousExitUnverifiable' ] as const export type AgentSessionFailureKind = (typeof AGENT_SESSION_FAILURE_KINDS)[number] diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 70c6121d03f5..225a0861d46e 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -67,8 +67,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'notResumable', 'noProviderChild', 'conversationHeldElsewhere', - /** A Stop could not prove its child gone, and a retry could not either: that child takes no - * input and none starts beside it. Sent with `ownerVerdict: 'unverifiable'`. */ + /** The close a stop began could not prove its child gone within the caller's wait: that child + * takes no input and none starts beside it. Sent with `ownerVerdict: 'unverifiable'`. */ 'previousExitUnverifiable' ], agent_session_conflict: [ From 902354e16468b13b9a451f3a793d52f9f83304d8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:35:57 -0700 Subject: [PATCH 004/269] fix(native-chat): a close's verdict is the root's exit alone, and what follows it is logged - A Claude close resolves as soon as the root's exit is proven: the session ends and its `ended` report goes out then. Saving the resume point runs afterwards and a failure is logged, so a slow or hung write never reads as an unproven exit or keeps a dead child on record. - A root that exits after its close came back unproven finishes that close through the same path as any close, so the session's child work is published as ended (background tasks and subagents no longer stay shown running for a dead agent), and a failure there is logged. - Codex logs a refused final row, and reports a root exit whose forced tree kill could not prove the rest of the tree gone the way Claude does, so the host logs it and blocks nothing. - Both adapters take the host's logger for this bookkeeping. --- .../claude-structured-session-adapter.ts | 23 ++-- .../claude-structured-session-close.test.ts | 8 +- .../claude/claude-structured-session-close.ts | 113 +++++++++++------- ...claude-structured-session-recovery.test.ts | 23 ++-- .../claude/claude-structured-session-state.ts | 3 + .../codex-app-server-connection-types.ts | 2 + src/main/codex/codex-app-server-connection.ts | 17 ++- .../codex/codex-structured-session-acquire.ts | 10 +- .../codex/codex-structured-session-adapter.ts | 1 + .../codex/codex-structured-session-close.ts | 20 +++- .../codex/codex-structured-session-state.ts | 3 + .../codex-structured-session-teardown.ts | 24 +++- .../structured-agent-session-runtime.ts | 2 + .../structured-claude-runtime-adapter.ts | 2 + 14 files changed, 161 insertions(+), 90 deletions(-) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index a752de5165d9..d52177e1d6a6 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -28,8 +28,8 @@ import { } from './claude-structured-session-state' import { closeAllClaudeSessions, - closeClaudePublishedSessionForDeps, - closeClaudeSession + closeClaudeSession, + finishClaudeCloseAfterExit } from './claude-structured-session-close' import { claudeStoppedRequestEndWait } from './claude-request-end-wait' import { @@ -99,14 +99,14 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda emit: (session, _events, event) => this.emit(session, event), handleExit: (sessionId, attempt, error) => observeClaudeSessionExit(this.exitLifecycle, sessionId, attempt, error), - finishClose: (sessionId, attempt) => { - if (this.sessions.get(sessionId)?.connection === attempt.connection) { - // Joins a close still running; finalizes one that came back unproven before the exit. - void closeClaudePublishedSessionForDeps(this.sessions, sessionId, this.deps).catch( - () => undefined - ) - } - }, + finishClose: (sessionId, attempt) => + finishClaudeCloseAfterExit({ + sessions: this.sessions, + sessionId, + connection: attempt.connection, + deps: this.deps, + afterClose: (close) => this.afterClose(sessionId, close) + }), settleExit: (sessionId, exit) => settleClaudeUnexpectedExit(this.exitLifecycle, sessionId, exit) } @@ -316,7 +316,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda sessions: this.sessions, acquisitions: this.acquisitions, ...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}), - ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}) + ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}), + ...(this.deps.logger ? { logger: this.deps.logger } : {}) }) closeAll = (): Promise => diff --git a/src/main/claude/claude-structured-session-close.test.ts b/src/main/claude/claude-structured-session-close.test.ts index 0e67b22ade4a..3d61bbb7357f 100644 --- a/src/main/claude/claude-structured-session-close.test.ts +++ b/src/main/claude/claude-structured-session-close.test.ts @@ -126,11 +126,9 @@ describe('Claude published session close lifecycle', () => { ).sessions.get('session-1') const disposeTranslator = vi.spyOn(session!.translator!, 'dispose') - // The exit is proven; the failed cursor write is reported, never read as an unproven exit. - await expect(adapter.closeSession('session-1')).rejects.toMatchObject({ - name: 'AgentSessionAcquisitionExitProvenError', - cause: persistenceError - }) + // The exit is proven; the failed cursor write after it never reads as an unproven exit. + await expect(adapter.closeSession('session-1')).resolves.toBe(true) + await vi.waitFor(() => expect(persistHandle).toHaveBeenCalledOnce()) // The child is provably dead; a failed cursor write may not suppress the end. expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) expect(events.filter((event) => event.type === 'handle')).toHaveLength(0) diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 05d767054094..903f49f052e7 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -19,6 +19,7 @@ import { closeProcessRegistry } from '../../shared/child-process/close-process-r import { retireClaudeDispatchWaiters } from './claude-structured-dispatch' import { settledClaudeTurnEndLeaf } from './claude-structured-resume-point' import { settleClaudeTurnEndWaiters } from './claude-request-end-wait' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' /** The root's own exit was seen first-hand. The lease follows the root, so a descendant * left unverified or seen alive does not hold it. */ @@ -86,6 +87,7 @@ type CloseClaudePublishedSessionInput = { fence: number }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void + logger?: StructuredAgentSessionLogger } async function finalizeClaudePublishedSession( @@ -123,27 +125,10 @@ async function finalizeClaudePublishedSession( } session.childWork.clear() session.backgroundTasks.clear() - const leafUuid = await settledClaudeTurnEndLeaf(session) - const persistence = - session.closePersistence ?? - (session.closePersistence = (async () => { - await input.persistHandle?.({ - sessionId: input.sessionId, - providerSessionId: session.providerSessionId, - leafUuid, - fence: session.fence - }) - })()) - const ended = { - type: 'ended', - sessionId: input.sessionId, - reason: 'claude session closed', - // The host ends the child's record on it, whoever was still waiting on the close. - cause: 'requested-close', - fence: session.fence, - acquisitionGeneration: session.acquisitionGeneration, - observedAt: Date.now() - } as const + // The exit is proven, so the session ends now. Saving its resume point is bookkeeping that + // follows, reported on failure; it never holds the close or reads as an unproven exit. + session.closeFinalized = true + input.sessions.delete(input.sessionId) let callbackError: unknown let callbackThrew = false const deliver = (event: ClaudeStructuredSessionEvent): void => { @@ -154,26 +139,18 @@ async function finalizeClaudePublishedSession( callbackError ??= error } } - let persistenceError: unknown - try { - await persistence - deliver({ - type: 'handle', - sessionId: input.sessionId, - providerSessionId: session.providerSessionId, - leafUuid, - fence: session.fence - }) - } catch (error) { - persistenceError = error - } - // The connection already proved the child dead, so the session has ended whatever the resume - // point's write did: that write is bookkeeping, reported below, never a reason to keep a dead - // child indexed as if its exit were unproven. - session.closeFinalized = true - input.sessions.delete(input.sessionId) if (!session.closeEnded) { session.closeEnded = true + const ended = { + type: 'ended', + sessionId: input.sessionId, + reason: 'claude session closed', + // The host ends the child's record on it, whoever was still waiting on the close. + cause: 'requested-close', + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration, + observedAt: Date.now() + } as const try { try { session.translator?.handle(ended) @@ -186,18 +163,41 @@ async function finalizeClaudePublishedSession( session.translator?.dispose() } } + session.closePersistence ??= persistClosedClaudeSession(input, session) if (rootExitVerdict) { throw rootExitVerdict } - if (persistenceError !== undefined || callbackThrew) { + if (callbackThrew) { // The exit is proven; only what followed it failed, which the caller reports. - throw new AgentSessionAcquisitionExitProvenError( - persistenceError !== undefined ? persistenceError : callbackError - ) + throw new AgentSessionAcquisitionExitProvenError(callbackError) } return true } +/** The resume point a closed session leaves for the next start, after the close already ended. */ +async function persistClosedClaudeSession( + input: CloseClaudePublishedSessionInput, + session: ClaudeSession +): Promise { + try { + const leafUuid = await settledClaudeTurnEndLeaf(session) + const handle = { + sessionId: input.sessionId, + providerSessionId: session.providerSessionId, + leafUuid, + fence: session.fence + } + await input.persistHandle?.(handle) + input.onEvent?.({ type: 'handle', ...handle }) + } catch (error) { + input.logger?.warn("saving a closed Claude session's resume point failed", { + scope: 'claude-close-resume-point', + sessionId: input.sessionId, + error + }) + } +} + export async function closeClaudePublishedSession( input: CloseClaudePublishedSessionInput ): Promise { @@ -233,11 +233,37 @@ export function closeClaudePublishedSessionForDeps( fence: number }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void + logger?: StructuredAgentSessionLogger } ): Promise { return closeClaudePublishedSession({ sessions, sessionId, ...deps }) } +/** The root exited after a close came back unproven: joins a close still running, or finishes that + * one for this exact child, through `afterClose`, which publishes the session's child work like + * any close. What failed after the exit is reported. */ +export function finishClaudeCloseAfterExit(input: { + sessions: Map + sessionId: string + connection: ClaudeStreamJsonConnection | null + deps: Parameters[2] + afterClose: (close: () => Promise) => Promise +}): void { + const { sessions, sessionId, deps } = input + if (sessions.get(sessionId)?.connection !== input.connection) { + return + } + void input + .afterClose(() => closeClaudePublishedSessionForDeps(sessions, sessionId, deps)) + .catch((error: unknown) => + deps.logger?.warn('finishing a Claude close after its process exited reported', { + scope: 'claude-close-after-exit', + sessionId, + error + }) + ) +} + export async function closeClaudeSession(input: { sessionId: string sessions: Map @@ -249,6 +275,7 @@ export async function closeClaudeSession(input: { fence: number }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void + logger?: StructuredAgentSessionLogger }): Promise { const attempt = input.acquisitions.get(input.sessionId) if (!(await cancelClaudeAcquisitionAttempt(attempt))) { diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index 7005f93f5837..a91b1d7c1935 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -95,11 +95,11 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { ).sessions.get('session-1') const disposeTranslator = vi.spyOn(session!.translator!, 'dispose') - await expect(adapter.closeSession('session-1')).rejects.toMatchObject({ - name: 'AgentSessionAcquisitionExitProvenError', - cause: callbackError - }) - expect(events.filter((event) => event.type === 'handle')).toHaveLength(1) + // The handle follows the proven close as bookkeeping; its callback's failure is reported. + await expect(adapter.closeSession('session-1')).resolves.toBe(true) + await vi.waitFor(() => + expect(events.filter((event) => event.type === 'handle')).toHaveLength(1) + ) expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) expect(disposeTranslator).toHaveBeenCalledOnce() }) @@ -114,11 +114,9 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { const adapter = adapterFor(claude, {}, [], [], undefined, persistHandle) await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - // The exit is proven, so the close reports the write and still ends the session. - await expect(adapter.closeSession('session-1')).rejects.toMatchObject({ - name: 'AgentSessionAcquisitionExitProvenError', - cause: persistenceError - }) + // The exit is proven, so the close ends the session; the write after it is bookkeeping. + await expect(adapter.closeSession('session-1')).resolves.toBe(true) + await vi.waitFor(() => expect(persistHandle).toHaveBeenCalledOnce()) await expect(adapter.closeSession('session-1')).resolves.toBe(true) expect(persistHandle).toHaveBeenCalledOnce() }) @@ -147,6 +145,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { await adapter.closeSession('session-1') + await vi.waitFor(() => expect(persistedHandles).toHaveLength(1)) expect(persistedHandles).toEqual([ { sessionId: 'session-1', @@ -155,7 +154,9 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { fence: 7 } ]) - expect(events.at(-2)).toEqual({ + // Written after the close already ended the session: its end comes first. + expect(events.at(-2)).toMatchObject({ type: 'ended', cause: 'requested-close' }) + expect(events.at(-1)).toEqual({ type: 'handle', sessionId: 'session-1', providerSessionId: PROVIDER_SESSION_ID, diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 670f28e5531b..876090c07e4d 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -1,3 +1,4 @@ +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import type { @@ -115,6 +116,8 @@ export type ClaudeStructuredSessionAdapterDeps = { leafUuid: string | null fence: number }) => Promise + /** Where bookkeeping a close or exit does after the child is gone reports a failure. */ + logger?: StructuredAgentSessionLogger /** Advance the durable resume point in place at a turn end; bookkeeping, never a turn failure. */ persistResumePoint?: (input: { sessionId: string diff --git a/src/main/codex/codex-app-server-connection-types.ts b/src/main/codex/codex-app-server-connection-types.ts index 8c6d4c13d9dd..bf33bc5d0b80 100644 --- a/src/main/codex/codex-app-server-connection-types.ts +++ b/src/main/codex/codex-app-server-connection-types.ts @@ -32,4 +32,6 @@ export type CodexAppServerConnection = { resumeReading?: () => void /** Resolves true only after the child emitted `exit` or `close`; false is unproven. */ close: () => Promise + /** The root exited, but the forced kill of its process tree could not prove the tree gone. */ + readonly processTreeUnproven?: boolean } diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index e68761040351..070d3e3c301c 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -79,6 +79,7 @@ export async function openCodexAppServerConnection( let exitObserved = false let closing = false let exitReported = false + let processTreeUnproven = false const exitProof = new RetryableProcessExitProof() /** First terminal cause, or null while the transport is still usable. Set once: * a child that dies reaches us through several listeners, and the specific @@ -258,16 +259,9 @@ export async function openCodexAppServerConnection( if (!exited) { const treeExited = await terminateProcessTree() await waitForProcessExitUntil(exitPromise, FORCED_EXIT_MS) - if (!treeExited && exitObserved) { - // The lease follows the root, which is gone: a child left behind is reported, and - // blocks nothing. - console.warn( - '[codex-app-server] root exited but its process tree was not proven gone', - { - pid: child.pid - } - ) - } + // The lease follows the root, which is gone: a child left behind is reported by the + // owner, and blocks nothing. + processTreeUnproven = !treeExited && exitObserved } } dispatcher.failPending(new Error('codex app-server connection closed')) @@ -282,6 +276,9 @@ export async function openCodexAppServerConnection( get closed() { return closing || exited || terminalError !== null }, + get processTreeUnproven() { + return processTreeUnproven + }, request, notify, respond: (id, result) => writeResponse({ id, result }), diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index bf40afa199bd..5d87745bf8b0 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -111,7 +111,14 @@ export async function acquireCodexStructuredSession(input: { previous: previousAttempt }) acquisitions.assertCurrent(sessionId, attempt) - if (!(await closeCodexPublishedSession(sessions, sessionId, deps.onEvent))) { + if ( + !(await closeCodexPublishedSession( + sessions, + sessionId, + deps.onEvent, + deps.logger ? { logger: deps.logger } : {} + )) + ) { throw new Error(`codex app-server for session ${sessionId} could not be stopped`) } acquisitions.assertCurrent(sessionId, attempt) @@ -172,6 +179,7 @@ export async function acquireCodexStructuredSession(input: { error, // The end of a close Orca began, even one that came back unproven before it. ...(exit?.expected ? { closedByOrca: true as const } : {}), + ...(deps.logger ? { logger: deps.logger } : {}), prompts: acquisition.prompts, ...(deps.onEvent ? { onEvent: deps.onEvent } : {}) }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 13a687a72cdc..2d3e1eda8fe1 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -76,6 +76,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap sessions: this.sessions, acquisitions: this.acquisitions, ...(deps.onEvent ? { onEvent: deps.onEvent } : {}), + ...(deps.logger ? { logger: deps.logger } : {}), forgetNotificationRetries: (sessionId) => this.notificationRetries.clear(sessionId, null) }) } diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 0afd70a70e8c..3ab803101aa0 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -8,6 +8,7 @@ import { type CodexStructuredSessionEvent } from './codex-structured-session-state' import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' export function handleCodexSessionExit(input: { sessions: Map @@ -18,6 +19,7 @@ export function handleCodexSessionExit(input: { closedByOrca?: true prompts?: CodexSession['prompts'] onEvent?: (event: CodexStructuredSessionEvent) => void + logger?: StructuredAgentSessionLogger }): boolean { const session = input.sessions.get(input.sessionId) if (!session || session.connection !== input.connection || session.ended) { @@ -43,7 +45,14 @@ export function handleCodexSessionExit(input: { // A synchronous sink rejection (usually backpressure) leaves the terminal rows to the host's // exit settlement, which writes its own bounded fallback. The exit itself is observed, so the // session ends either way: holding a dead child as unproven over a refused row would strand it. - session.translator?.handle(event) + const admission = session.translator?.handle(event) ?? { accepted: true } + if (!admission.accepted) { + input.logger?.warn("Codex's final rows were refused; the host settles the turn instead", { + scope: 'codex-exit-rows', + sessionId: input.sessionId, + reason: admission.reason + }) + } session.ended = true // Nothing can echo for this child any more; the journal's pending-submission // recovery is what settles the sends these were armed for. @@ -63,6 +72,7 @@ export async function closeCodexPublishedSession( sessionId: string, onEvent?: (event: CodexStructuredSessionEvent) => void, options?: { + logger?: StructuredAgentSessionLogger requestedClose?: boolean expectedFence?: number expectedAcquisitionGeneration?: string @@ -97,7 +107,8 @@ export async function closeCodexPublishedSession( error: options?.unexpectedReason ?? new Error('codex session closed'), closedByOrca: true, prompts: session.prompts, - ...(onEvent ? { onEvent } : {}) + ...(onEvent ? { onEvent } : {}), + ...(options?.logger ? { logger: options.logger } : {}) }) } sessions.delete(sessionId) @@ -108,7 +119,8 @@ export async function closeCodexSession( sessionId: string, sessions: Map, acquisitions: CodexAcquisitionRegistry, - onEvent?: (event: CodexStructuredSessionEvent) => void + onEvent?: (event: CodexStructuredSessionEvent) => void, + logger?: StructuredAgentSessionLogger ): Promise { const attempt = acquisitions.get(sessionId) if (!(await cancelCodexAcquisitionAttempt(attempt))) { @@ -117,7 +129,7 @@ export async function closeCodexSession( if (attempt) { acquisitions.deleteIfCurrent(sessionId, attempt) } - return closeCodexPublishedSession(sessions, sessionId, onEvent) + return closeCodexPublishedSession(sessions, sessionId, onEvent, logger ? { logger } : {}) } export async function closeAllCodexSessions( diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index a47cb5a78fe2..74246b6a7f17 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -1,3 +1,4 @@ +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import type { AgentJournalItemIdentity, AgentSessionJournalIdentity @@ -77,6 +78,8 @@ export type CodexStructuredSessionAdapterDeps = { /** Host capability seam; production uses the native Windows process table. */ isWindowsProcessStartTimeAvailable?: () => boolean onEvent?: (event: CodexStructuredSessionEvent) => void + /** Where bookkeeping a close or exit does after the child is gone reports a failure. */ + logger?: StructuredAgentSessionLogger /** What the session's child work did, delivered after the journal handled the frame. */ onChildWorkEvidence?: (sessionId: string, evidence: AgentChildWorkEvidence[]) => void /** A send admitted earlier: its identity once Codex echoes it, or its rejection when the turn diff --git a/src/main/codex/codex-structured-session-teardown.ts b/src/main/codex/codex-structured-session-teardown.ts index 115e794d8310..e7395fb3c845 100644 --- a/src/main/codex/codex-structured-session-teardown.ts +++ b/src/main/codex/codex-structured-session-teardown.ts @@ -4,6 +4,8 @@ // session owns are cleared exactly once, and only when the child was actually // proven stopped — a refused close leaves the session indexed for a retry. +import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { closeAllCodexSessions, closeCodexPublishedSession, @@ -19,6 +21,7 @@ export type CodexStructuredSessionTeardownDeps = { sessions: Map acquisitions: CodexAcquisitionRegistry onEvent?: (event: CodexStructuredSessionEvent) => void + logger?: StructuredAgentSessionLogger forgetNotificationRetries: (sessionId: string) => void } @@ -26,13 +29,24 @@ export class CodexStructuredSessionTeardown { constructor(private readonly deps: CodexStructuredSessionTeardownDeps) {} close = async (sessionId: string): Promise => { - const closed = await closeCodexSession( + const connection = this.deps.sessions.get(sessionId)?.connection + const closed = this.settled( sessionId, - this.deps.sessions, - this.deps.acquisitions, - this.deps.onEvent + await closeCodexSession( + sessionId, + this.deps.sessions, + this.deps.acquisitions, + this.deps.onEvent, + this.deps.logger + ) ) - return this.settled(sessionId, closed) + if (closed && connection?.processTreeUnproven) { + // The root exited, so the close is proven; its owner reports the children left unconfirmed. + throw new AgentSessionAcquisitionRootExitObservedError( + new Error('codex app-server exited, but its process tree was not proven gone') + ) + } + return closed } forceClose = async (sessionId: string): Promise => { diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 4c621a5e0c3b..b5efd11efad1 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -270,6 +270,7 @@ async function installOnJournal( host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, onPrimaryThreadStoppedRunning: releaseUnansweredDispatches, + logger: deps.logger, onEvent: (event) => { // Every exit, expected or not: the host ends that child's record. if (event.type === 'ended' && 'cause' in event) { @@ -294,6 +295,7 @@ async function installOnJournal( } : {}), onLifecycleEvent: (event) => lifecycle.deliver(event), + logger: deps.logger, onChildWorkEvidence: (sessionId, evidence) => host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 246f5b5eea3f..732515cdde60 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -38,6 +38,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'] onSessionIdle?: ClaudeStructuredSessionAdapterDeps['onSessionIdle'] onChildWorkEvidence?: ClaudeStructuredSessionAdapterDeps['onChildWorkEvidence'] + logger?: ClaudeStructuredSessionAdapterDeps['logger'] } /** The adapter events the host's lifecycle handler consumes, in the host's vocabulary. */ @@ -132,6 +133,7 @@ export function createStructuredClaudeRuntimeAdapter( ...(deps.onDispatchSettledLate ? { onDispatchSettledLate: deps.onDispatchSettledLate } : {}), ...(deps.onSessionIdle ? { onSessionIdle: deps.onSessionIdle } : {}), ...(deps.onChildWorkEvidence ? { onChildWorkEvidence: deps.onChildWorkEvidence } : {}), + ...(deps.logger ? { logger: deps.logger } : {}), ...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), ...(deps.modelCatalog ? { modelCatalog: deps.modelCatalog } : {}) From 11fb59323f2495a3a9c035b66ac16c7ad2510465 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:36:16 -0700 Subject: [PATCH 005/269] fix(native-chat): one handler ends every child's exit, and a join waits on the adapter's own close - One exit handler (`structured-agent-session-child-exit`) ends a child's record for an exit expected or not. `expected` only changes what the chat is told: the stop's cause, its end at the stop's ask, the settlement id, and no crash outcome row. The lease release keeps the exit's evidence; the handoff guard, lifecycle barrier, sink release and adapter acknowledgement apply to both. A Claude journal-sink failure ends in the same step as its stop, as Orca's own fault. - Joining a close is asking the adapter, whose close is memoized while it runs and bounded by its own kill escalation; the host keeps no attempt of its own and no 10 s caller bound. An ask after a close came back unproven runs the stop again. - A close's end is stamped where its stop was asked for (a repeated ask moves it), so the closed chat and failed start checks order a message accepted meanwhile after it. - A start refused because the old exit is unverifiable rejects what was queued in the same step. - The end of a close the host asked for no longer waits on the cross-session recovery chain. - The kill no longer waits for the stop event's write; the journal writes rows in order. --- ...structured-agent-session-attach-context.ts | 4 + .../structured-agent-session-child-close.ts | 200 +++------------- .../structured-agent-session-child-exit.ts | 218 ++++++++++++++++++ ...red-agent-session-claude-root-exit.test.ts | 18 +- ...ssion-claude-stop-exit-ends-record.test.ts | 164 ++++++++++--- ...tured-agent-session-codex-stop-row.test.ts | 21 +- .../structured-agent-session-delivery-loop.ts | 32 ++- ...structured-agent-session-event-recovery.ts | 65 +++--- ...uctured-agent-session-eviction-deadline.ts | 48 ---- .../structured-agent-session-eviction.test.ts | 63 ++--- .../structured-agent-session-eviction.ts | 30 +-- .../structured-agent-session-host-lifetime.ts | 35 +-- ...tructured-agent-session-host-stop-event.ts | 9 +- .../structured-agent-session-host-types.ts | 7 +- .../structured-agent-session-host.ts | 5 +- .../structured-agent-session-lease-release.ts | 47 +--- ...structured-agent-session-provider-child.ts | 8 +- ...agent-session-startup-failure-exit.test.ts | 22 +- ...d-agent-session-stop-event-binding.test.ts | 3 + ...d-agent-session-stop-event-entries.test.ts | 3 + ...ured-agent-session-unexpected-exit.test.ts | 30 +-- ...tructured-agent-session-unexpected-exit.ts | 199 ---------------- ...d-agent-session-lifecycle-delivery.test.ts | 49 ++++ ...ctured-agent-session-lifecycle-delivery.ts | 9 +- 24 files changed, 629 insertions(+), 660 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts create mode 100644 src/main/runtime/structured-agent-session-lifecycle-delivery.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index 90c638c23fb9..38cdb8fdb5be 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -14,6 +14,7 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import type { StructuredAgentSessionConversationOpenOptions } from './structured-agent-session-conversation-open' @@ -40,6 +41,9 @@ export type StructuredAgentSessionAttachContext = { serialize: (sessionId: string, task: () => Promise) => Promise now: () => number publishStatus: (sessionId: string) => void + /** Joining a stop's close ends the child's record through the one exit handler. */ + endExitedChild: StructuredAgentSessionLifetimeContext['endExitedChild'] + wakeDelivery?: StructuredAgentSessionLifetimeContext['wakeDelivery'] /** The conversation's one open journal, opened when closed; see `conversation-open`. */ openConversation: ( sessionId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts index 27bcd9c6fb53..d9232d142124 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts @@ -1,57 +1,35 @@ -// A provider child's close: one per child, joined by every stop, start and provider write that -// meets it, and the one place a close's proven exit ends the child's record. +// A provider child's close, which every stop, start and provider write that meets it joins. // // The close lives on the child (`child.close`) and ends with it, so nothing outlives the process -// it is about. A caller waits a bounded time for the exit's proof; past the bound it answers -// `unverifiable` and the close keeps running. Whenever the exit is proven — by a caller still -// waiting, or by the adapter's own report of it — the record ends here. +// it is about. Joining it is asking the adapter to close: the adapter runs one close per child at +// a time and bounds it by its own kill escalation, so every caller waits on the same attempt, and +// a caller that comes after an attempt ended unproven runs it again. The verdict is the root's +// exit alone. Proven, the one exit handler ends the record; a proof that lands with no caller +// waiting reaches that handler as the adapter's report of the exit. -import { withTimeout } from '../../../shared/promise-timeout-fallback' import { refuse } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import { - evictStructuredAgentSession, - STRUCTURED_AGENT_SESSION_EVICTION_STEPS -} from './structured-agent-session-eviction' -import { - STRUCTURED_AGENT_SESSION_EVICTION_STEP_TIMEOUT_MS, - withStructuredAgentSessionEvictionDeadline -} from './structured-agent-session-eviction-deadline' import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' -import type { - StructuredAgentSessionChildClose, - StructuredAgentSessionHostSession, - StructuredAgentSessionProviderChild -} from './structured-agent-session-host-types' -import { endProviderChild } from './structured-agent-session-provider-child' +import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' -import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' -import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' -import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' +import { releaseStoredStructuredAgentSessionOwnerAfterExit } from './structured-agent-session-lease-release' import { isSurfaceReleasableAgentSessionRecord } from '../../runtime/agent-session-surface-release-transition' -/** What a caller learned about the child's exit: proven, or not within its bound. Loss of contact - * and a close that ran out of its own escalation both read `unverifiable`, never `exited`. */ +/** What a caller learned about the child's exit: proven, or not. A close that ran out of its own + * escalation, or a host Orca lost contact with, reads `unverifiable`, never `exited`. */ export type StructuredAgentSessionChildCloseVerdict = 'exited' | 'unverifiable' -/** Joins the child's close, starting another attempt when the last one ended unproven, and waits - * for its proof as long as the caller may. A proof ends the record before this resolves. */ +/** Joins the child's close and, once its root's exit is proven, ends the record. */ export async function joinStructuredAgentSessionChildClose( context: StructuredAgentSessionLifetimeContext, sessionId: string, - child: StructuredAgentSessionProviderChild, - close: StructuredAgentSessionChildClose + child: StructuredAgentSessionProviderChild ): Promise { - close.attempt ??= closeAttempt(context, sessionId, close) - const proven = await withTimeout( - close.attempt, - STRUCTURED_AGENT_SESSION_EVICTION_STEP_TIMEOUT_MS, - false - ) - if (!proven) { + if (!(await closeProviderRoot(context, sessionId))) { return 'unverifiable' } - await endClosedStructuredAgentSessionChildUnderSerialize(context, sessionId, child) + await context.endExitedChild(sessionId, child, { expected: true, reason: 'closed by Orca' }) + context.restartWitness?.stopped(sessionId) return 'exited' } @@ -75,33 +53,30 @@ export async function joinClosingStructuredAgentSessionChild( if (!child?.close) { return { ok: true } } - return (await joinStructuredAgentSessionChildClose(context, sessionId, child, child.close)) === - 'exited' + return (await joinStructuredAgentSessionChildClose(context, sessionId, child)) === 'exited' ? { ok: true } : { ok: false, refusal: previousExitUnverifiableRefusal() } } -function closeAttempt( +function closeProviderRoot( context: StructuredAgentSessionLifetimeContext, - sessionId: string, - close: StructuredAgentSessionChildClose + sessionId: string ): Promise { const { adapter, logger } = context.deps // An adapter with no close has nothing to stop; anything else must PROVE the exit. const stop = adapter.disposeSession ?? adapter.closeSession - const attempt = ( - stop - ? stopAgentSessionProviderRoot( - () => stop.call(adapter, sessionId), - // The exit is proven; a child process left behind, or a write after it, is reported only. - (error) => - logger.warn("the agent's process exited, but its close did not finish cleanly", { - scope: 'provider-close-after-exit', - sessionId, - error - }) - ) - : Promise.resolve(true) + if (!stop) { + return Promise.resolve(true) + } + return stopAgentSessionProviderRoot( + () => stop.call(adapter, sessionId), + // The exit is proven; a child process left behind, or a write after it, is reported only. + (error) => + logger.warn("the agent's process exited, but its close did not finish cleanly", { + scope: 'provider-close-after-exit', + sessionId, + error + }) ).catch((error: unknown) => { logger.warn("closing the agent's process did not prove it exited", { scope: 'provider-close', @@ -110,14 +85,6 @@ function closeAttempt( }) return false }) - // Unproven, the next ask starts another attempt. A proof that lands after every caller stopped - // waiting needs nothing here: the adapter reports that exit, which ends the record. - void attempt.then((proven) => { - if (!proven && close.attempt === attempt) { - close.attempt = undefined - } - }) - return attempt } /** Whether the lease still names the child this host last proved gone, unreleased: only that @@ -136,7 +103,7 @@ export function structuredAgentSessionEndedChildHoldsLease( ) } -/** Writes the release a proven exit allows when its wind-down could not: a start and the +/** Writes the release a proven exit allows when the exit handler could not: a start and the * handle's close re-derive it, and a failure is reported, never anyone's refusal. Resolves * whether the lease still names that child. */ export async function releaseLeaseOfEndedStructuredAgentSessionChild( @@ -147,12 +114,12 @@ export async function releaseLeaseOfEndedStructuredAgentSessionChild( if (!ended || !structuredAgentSessionEndedChildHoldsLease(context, sessionId)) { return false } - await releaseStoredStructuredAgentSessionOwner({ + await releaseStoredStructuredAgentSessionOwnerAfterExit({ store: context.deps.store, sessionId, - hasProviderChild: true, expectedFence: ended.fence, - now: context.now() + now: context.now(), + ...(ended.reason ? { exitReason: ended.reason } : {}) }).catch((error: unknown) => context.deps.logger.warn("releasing an exited agent's lease failed", { scope: 'ended-child-lease-release', @@ -162,102 +129,3 @@ export async function releaseLeaseOfEndedStructuredAgentSessionChild( ) return structuredAgentSessionEndedChildHoldsLease(context, sessionId) } - -/** The adapter's report that a child it was asked to close is gone ends the record: a proof that - * landed with no caller left waiting, or a root that exited after its close gave up. */ -export function endClosedStructuredAgentSessionChild( - context: StructuredAgentSessionLifetimeContext, - event: StructuredAgentSessionEndedEvent -): Promise { - return context.serialize(event.sessionId, async () => { - const child = context.sessions.get(event.sessionId)?.child - if (child && child.fence === event.fence && child.generation === event.acquisitionGeneration) { - await endClosedStructuredAgentSessionChildUnderSerialize(context, event.sessionId, child) - } - }) -} - -/** One wind-down per child, which every caller that saw its exit proven awaits. */ -const windDowns = new WeakMap>() - -/** - * The child's process is proven gone: it leaves the record, then every wind-down step is - * attempted, each failure reported, none able to keep it on record or gate the next send. Then - * the delivery loop hands over whatever is queued. Joins the wind-down already running for it. - */ -export function endClosedStructuredAgentSessionChildUnderSerialize( - context: StructuredAgentSessionLifetimeContext, - sessionId: string, - child: StructuredAgentSessionProviderChild -): Promise { - const running = windDowns.get(child) - if (running) { - return running - } - const session = context.sessions.get(sessionId) - if (!session || session.child !== child) { - return Promise.resolve() - } - const windDown = windDownClosedChild(context, sessionId, session, child) - windDowns.set(child, windDown) - return windDown -} - -async function windDownClosedChild( - context: StructuredAgentSessionLifetimeContext, - sessionId: string, - session: StructuredAgentSessionHostSession, - child: StructuredAgentSessionProviderChild -): Promise { - const { deps } = context - const close = child.close - endProviderChild(session, { - generation: child.generation, - fence: child.fence, - cause: close?.cause ?? 'evict', - reason: close?.reason ?? null, - duringStartup: child.phase === 'starting', - rootGone: true - }) - context.restartWitness?.stopped(sessionId) - await evictStructuredAgentSession( - { - sessionId, - eventSink: context.runtimeState.eventSinkFor(sessionId), - logger: deps.logger, - acknowledgeRelease: () => deps.adapter.acknowledgeSessionRelease?.(sessionId), - discardSink: () => context.runtimeState.discardEventSink(sessionId), - settleWork: async () => { - // Folded before the fallback's end is built, so the end reads it (`turnEndAfterStop`). - await close?.recorded - const settled = await settleStructuredAgentSessionDeadGeneration({ - journal: session.journal, - sessionId, - fence: child.fence, - settlementId: `expected-close:${sessionId}:${child.fence}:${child.generation ?? 'unknown'}`, - pendingSubmissionReason: 'provider_closed_before_acknowledgement', - // Only a turn no adapter settled: one with no close, or whose settle threw. Whether it - // was a person's Stop is its event's to say (`turnEndAfterStop`). - verdict: { state: 'interrupted', completedAt: context.now() }, - showUnexpectedExitOutcome: false - }) - if (!settled.ok) { - throw new Error('dead generation work settlement failed', { cause: settled.error }) - } - }, - releaseLease: () => - releaseStoredStructuredAgentSessionOwner({ - store: deps.store, - sessionId, - hasProviderChild: true, - expectedFence: child.fence, - now: context.now() - }) - }, - withStructuredAgentSessionEvictionDeadline(STRUCTURED_AGENT_SESSION_EVICTION_STEPS) - ) - // Whatever ended the child, the row belongs to the conversation: it shows not-running, and only - // the conversation's close forgets it. - context.publishStatus?.(sessionId) - context.wakeDelivery?.(sessionId) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts new file mode 100644 index 000000000000..8a470f209187 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -0,0 +1,218 @@ +// The one handler that ends a provider child's record, whether its exit was expected (a close this +// host asked for) or not (the child died on its own). The exit was seen or proven first-hand, so +// every step after it is bookkeeping: each is attempted and reported, none keeps the child on +// record, and the record ends in `finally`. `expected` changes only what the chat is told. + +import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' +import type { + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChild +} from './structured-agent-session-host-types' +import { endProviderChild } from './structured-agent-session-provider-child' +import { + releaseStoredStructuredAgentSessionOwnerAfterExit, + type StructuredAgentSessionLeaseStore +} from './structured-agent-session-lease-release' +import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' +import { + captureUnfinishedStructuredAgentSessionWork, + MAX_UNEXPECTED_EXIT_REASON_CHARS, + settleStructuredAgentSessionDeadGeneration, + type DeadGenerationJournal, + unfinishedStructuredAgentSessionWorkWasInterrupted +} from './structured-agent-session-dead-generation-settlement' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import { evictStructuredAgentSession } from './structured-agent-session-eviction' +import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' + +/** How the child's root went: the close this host asked for, or a death of its own. */ +export type StructuredAgentSessionChildExit = { + expected: boolean + /** Log text only; the chat's words come from `failure`. */ + reason: string + failure?: SubmissionRejectionFact + /** Host receipt of the exit: the end time of a turn it interrupted. */ + observedAt?: number + startupUnproven?: true +} + +export type StructuredAgentSessionChildExitSession = Pick< + StructuredAgentSessionHostSession, + 'child' | 'lastEndedChild' +> & { journal: DeadGenerationJournal & Pick } + +export type StructuredAgentSessionChildExitContext< + TSession extends StructuredAgentSessionChildExitSession = StructuredAgentSessionHostSession +> = { + store: StructuredAgentSessionLeaseStore + sessions: Map + flushLifecycle: (sessionId: string) => Promise + publishFence: (sessionId: string, session: TSession) => void + publishStatus?: (sessionId: string) => void + /** The delivery loop hands over whatever is queued once the child is off the record. */ + wakeDelivery?: (sessionId: string) => void + serialize: (sessionId: string, task: () => Promise) => Promise + now: () => number + logger: StructuredAgentSessionLogger + /** Lets the child's sink and the adapter's route for it go; absent leaves both to the next attach. */ + route?: { + runtimeState: Pick + acknowledgeRelease: (sessionId: string) => Promise | void + } +} + +/** An adapter's report of a child's exit, for the child still on record with its identity. */ +export function settleStructuredAgentSessionChildExit< + TSession extends StructuredAgentSessionChildExitSession +>( + context: StructuredAgentSessionChildExitContext, + event: StructuredAgentSessionEndedEvent +): Promise { + return context.serialize(event.sessionId, async () => { + const child = context.sessions.get(event.sessionId)?.child + if (!child || child.fence !== event.fence || child.generation !== event.acquisitionGeneration) { + return + } + await endExitedStructuredAgentSessionChildUnderSerialize(context, event.sessionId, child, { + expected: event.cause === 'requested-close', + reason: event.reason, + ...(event.failure ? { failure: event.failure } : {}), + ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), + ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}) + }) + }) +} + +/** Ends the record of a child whose exit this host saw or proved; a no-op once it has left. */ +export async function endExitedStructuredAgentSessionChildUnderSerialize< + TSession extends StructuredAgentSessionChildExitSession +>( + context: StructuredAgentSessionChildExitContext, + sessionId: string, + child: StructuredAgentSessionProviderChild, + exit: StructuredAgentSessionChildExit +): Promise { + const session = context.sessions.get(sessionId) + if (!session || session.child !== child) { + return + } + const { expected } = exit + const close = child.close + // Receipt of the exit is the one end time the host may record for a running turn. + const observedAt = exit.observedAt ?? context.now() + // The host's own phase decides, so a provider that omits the flag still gets a start that + // failed told as one: the row says so. + const exitedDuringStartup = exit.startupUnproven === true || child.phase === 'starting' + const endChild = (): void => { + endProviderChild(session, { + generation: child.generation, + fence: child.fence, + // A close keeps the cause of the stop that asked for it, and ends where it was asked. + cause: expected ? (close?.cause ?? 'evict') : 'exit', + reason: expected ? (close?.reason ?? null) : exit.reason, + ...(!expected && exit.failure ? { failure: exit.failure } : {}), + duringStartup: exitedDuringStartup, + // The adapter publishes an exit only once it saw the root go, first-hand or proven. + rootGone: true, + ...(expected && close ? { endedAt: close.requestedAt } : {}) + }) + context.publishStatus?.(sessionId) + } + const record = context.store.getRecord(sessionId) + if (!record || record.lease.handoffStage !== null) { + // An acquisition or recovery already owns this lease's transition. + endChild() + context.wakeDelivery?.(sessionId) + return + } + const unfinishedWork = captureUnfinishedStructuredAgentSessionWork(session.journal) + try { + try { + const barrier = await context.flushLifecycle(sessionId) + if (!barrier.ok) { + logExitFailure(context, sessionId, 'exit-lifecycle-barrier', barrier.error) + } + } catch (error) { + logExitFailure(context, sessionId, 'exit-lifecycle-barrier', error) + } + // Folded before the fallback's end is built, so the end reads it (`turnEndAfterStop`). + await close?.recorded + const generation = child.generation ?? 'unknown' + const settled = await settleStructuredAgentSessionDeadGeneration({ + journal: session.journal, + sessionId, + fence: child.fence, + settlementId: `${expected ? 'expected-close' : 'provider-exit'}:${sessionId}:${child.fence}:${generation}`, + pendingSubmissionReason: expected + ? 'provider_closed_before_acknowledgement' + : 'provider_exited_before_acknowledgement', + // Only a turn no adapter settled. Whether a close ended a person's turn is the Stop event's to + // say (`turnEndAfterStop`); a death of its own interrupted it. + verdict: { state: 'interrupted', completedAt: expected ? context.now() : observedAt }, + failureTextContext: structuredAgentSessionFailureWordsContext(record, session.journal), + // A failed start always says why: no response was running to carry the reason. + showUnexpectedExitOutcome: + !expected && + (exitedDuringStartup || + unfinishedStructuredAgentSessionWorkWasInterrupted( + unfinishedWork, + session.journal, + observedAt + )), + ...(!expected && exit.failure ? { exitFailure: exit.failure } : {}), + ...(!expected && exitedDuringStartup && child.generation + ? { exitedDuringStartup: { generation: child.generation } } + : {}) + }) + if (!settled.ok) { + logExitFailure(context, sessionId, 'exit-settlement', settled.error) + } + } finally { + // The root's exit was observed, so the owner is released even when terminal settlement could + // not be durably accepted. Bare cause: whatever this settlement could not write is settled from + // it later (the settle recording it queues, or the next open or acquire). + let released = false + try { + await releaseStoredStructuredAgentSessionOwnerAfterExit({ + store: context.store, + sessionId, + expectedFence: child.fence, + now: context.now(), + exitObservedAt: observedAt, + exitReason: exit.reason.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS) + }) + released = true + } catch (error) { + logExitFailure(context, sessionId, 'exit-owner-release', error) + } + if (context.route) { + const { runtimeState, acknowledgeRelease } = context.route + await evictStructuredAgentSession({ + sessionId, + eventSink: runtimeState.eventSinkFor(sessionId), + logger: context.logger, + discardSink: () => runtimeState.discardEventSink(sessionId), + acknowledgeRelease: () => acknowledgeRelease(sessionId) + }) + } + endChild() + // A reader re-baselines on a death of the child's own; a close Orca asked for moves no fence a + // reader holds, as a client resends a message when its fence moves. + if (released && !expected) { + context.publishFence(sessionId, session) + } + context.wakeDelivery?.(sessionId) + } +} + +function logExitFailure( + context: Pick, + sessionId: string, + scope: string, + error: unknown +): void { + context.logger.warn('settling a provider exit did not finish', { scope, sessionId, error }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index 1867e4f4729f..bf473bfc6df0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -15,6 +15,7 @@ import { } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' +import { endExitedStructuredAgentSessionChildUnderSerialize } from './structured-agent-session-child-exit' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -136,7 +137,22 @@ describe('Claude root-exit stop', () => { sessions, now: () => NOW + 30 * 60_000, publishStatus, - serialize: (_sessionId, task) => task() + endExitedChild: (sessionId, child, exit) => + endExitedStructuredAgentSessionChildUnderSerialize( + { + store, + sessions, + flushLifecycle: (id) => runtimeState.lifecycleBarrier(id), + publishFence: () => undefined, + publishStatus, + serialize: (_sessionId, task) => task(), + now: () => NOW + 30 * 60_000, + logger: deps.logger + }, + sessionId, + child, + exit + ) }, 'session-1', { cause: 'evict' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts index 4e2ca9cf4b15..91ac994d4a74 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -20,7 +20,6 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { STRUCTURED_AGENT_SESSION_EVICTION_STEP_TIMEOUT_MS } from './structured-agent-session-eviction-deadline' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { HOST_TEST_NOW as NOW, @@ -41,12 +40,14 @@ let store: AgentSessionRecordStore let claude: ReturnType let log: ReturnType let persistHandle: ReturnType Promise>> +let childWork: string[] beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-claude-stop-exit-ends-record-')) resetHostTestOperationIds() log = recordingStructuredAgentSessionLogger() persistHandle = vi.fn(async () => undefined) + childWork = [] claude = fakeClaude({ replayUuid: null, routes: { interrupt: () => ({ still_queued: [], cancelled: [] }) } @@ -71,6 +72,11 @@ beforeEach(async () => { }, onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), persistHandle: () => persistHandle(), + logger: log.logger, + onChildWorkEvidence: (sessionId, evidence) => { + childWork.push(...evidence.map((edge) => edge.type)) + host.publishChildWorkEvidence(sessionId, evidence) + }, openConnection: claude.openConnection, readProcessStartTime: async () => 1_700_000_000_000, now: () => NOW @@ -284,7 +290,7 @@ it('joins the close a Stop could not prove before the next message, then sends i }) }) -it('has a send made while the close still runs join that close, not start another', async () => { +it('has a send made while the close still runs wait for that close, not start another', async () => { const connection = claude.connections[0]! await openTurn(connection) const proof = deferred() @@ -297,58 +303,50 @@ it('has a send made while the close still runs join that close, not start anothe return close() } refuseWritesOnceClosed(connection) - const closeSession = vi.spyOn(adapter, 'closeSession') await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) frame(connection, INTERRUPTED_RESULT) - await send('Carry on.') + // Accepted on the chat's queue behind the Stop's close, which nothing abandons meanwhile. + const sent = send('Carry on.') await eventually(() => expect(attempts).toBe(1)) expect(claude.connections).toHaveLength(1) proof.resolve() + await sent await resumedWith(connection, 'Carry on.') - // One close of the old child, which the send waited on rather than asking for its own. - expect(closeSession).toHaveBeenCalledOnce() + // One close of the old child, which the send waited on rather than starting its own. expect(attempts).toBe(1) expect(connection.closeCount).toBe(1) }) -// The caller's bound is how long it waits, never how long the close may take. -it('ends the record when the proof lands after every caller stopped waiting', async () => { +// The verdict is the root's exit; the resume point written after it is bookkeeping. +it('ends the record at the proven exit though the resume-point write after it never settles', async () => { const connection = claude.connections[0]! - const proof = deferred() - connection.close = async () => { - connection.closed = true - return proof.promise - } - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - let stopped: unknown - try { - const stopping = host['tasks'] - .serialize(SESSION, () => host['lifetime'].stopAgent(SESSION, { cause: 'evict' })) - .then( - () => 'proven', - (error: unknown) => error - ) - await vi.advanceTimersByTimeAsync(STRUCTURED_AGENT_SESSION_EVICTION_STEP_TIMEOUT_MS + 2_000) - stopped = await stopping - } finally { - vi.useRealTimers() - } - // Unverifiable for that caller, and the close still running: nothing claims the child exited. - expect(stopped).toMatchObject({ step: 'stop-provider-child' }) - expect(child()?.close?.attempt).toBeDefined() - expect(lease()?.claimStatus).toBe('live') - - proof.resolve(true) + await openTurn(connection) + refuseWritesOnceClosed(connection) + persistHandle.mockImplementationOnce(() => new Promise(() => {})) + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) await eventually(() => expect(child()).toBeNull()) - expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ cause: 'evict' }) - await eventually(() => expect(lease()?.claimStatus).toBe('released')) + expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ cause: 'user-stop' }) + await send('Carry on.') + await resumedWith(connection, 'Carry on.') }) it('ends the record when the root exits after its close gave up, with no one asking again', async () => { - const connection = await stopWithUnprovenClose(1) + const connection = claude.connections[0]! + // A background task the old agent was running when the Stop's close came back unproven. + frame(connection, { + type: 'system', + subtype: 'task_started', + uuid: 'task-start', + task_id: 'background-1', + task_type: 'local_agent', + is_backgrounded: true + }) + await stopWithUnprovenClose(1) + childWork.length = 0 // The connection reports the root's exit as the end of the close Orca began. connection.handlers.onExit?.(new Error('claude exited'), { expected: true }) @@ -358,6 +356,8 @@ it('ends the record when the root exits after its close gave up, with no one ask expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ cause: 'user-stop' }) expect(connection.closeCount).toBe(2) expect(claude.connections).toHaveLength(1) + // The child work ends with the session, so nothing is left shown running for a dead agent. + await eventually(() => expect(childWork).toContain('session-ended')) }) it('sends after a proven exit whose resume-point write and lease release both failed', async () => { @@ -377,7 +377,7 @@ it('sends after a proven exit whose resume-point write and lease release both fa // Both reported; neither kept the dead child on record. await eventually(() => expect(scopes()).toEqual( - expect.arrayContaining(['provider-close-after-exit', 'exit-wind-down']) + expect.arrayContaining(['claude-close-resume-point', 'exit-owner-release']) ) ) expect(lease()?.claimStatus).toBe('live') @@ -468,3 +468,93 @@ it('has the idle reaper join a close still unverifiable, ending the record with expect(lease()).toMatchObject({ claimStatus: 'released', ownerProcess: null }) expect(host.hasSession(SESSION)).toBe(false) }) + +it("ends a Claude journal-sink failure as Orca's own fault, never a quiet rest", async () => { + const connection = claude.connections[0]! + await openTurn(connection) + + host['eventRecovery'].recoverAfterSinkFailure(SESSION, new Error('journal write failed')) + + await eventually(() => expect(child()).toBeNull()) + expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ + cause: 'exit', + failure: { kind: 'hostFault' } + }) + expect(connection.closeCount).toBe(1) +}) + +it('delivers a message accepted after a tab close was asked for, when the late exit lands first', async () => { + const connection = claude.connections[0]! + closeUnprovenFor(connection, 1) + await expect(host.close(SESSION, 'user-close')).rejects.toThrow() + const closing = child()! + expect(closing.close).toMatchObject({ cause: 'user-close' }) + + // The message is accepted, then the exit report ends the child, before the delivery step runs. + const held = deferred() + const holding = host['tasks'].serialize(SESSION, () => held.promise) + const sent = send('Carry on.') + const ended = host['tasks'].serialize(SESSION, () => + host['eventRecovery'].endExitedChildUnderSerialize(SESSION, closing, { + expected: true, + reason: 'claude session closed' + }) + ) + held.resolve() + await holding + const next = await sent + await ended + + // The close was asked for before the message, so its end closes nothing the message carries. + await resumedWith(connection, 'Carry on.') + expect(await submission(next)).not.toMatchObject({ dispatchState: 'rejected' }) +}) + +it('fails only the message its own refusal is about, never one accepted while that start was refused', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + // The Stop's close and the first send's join both come back unproven; the second's proves. + const close = connection.close + const joined = deferred() + let attempts = 0 + connection.close = async () => { + attempts += 1 + connection.closeCount += 1 + connection.closed = true + if (attempts === 1) { + return false + } + return attempts === 2 ? joined.promise : close() + } + refuseWritesOnceClosed(connection) + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) + await laneDrained() + + const first = await send('First.') + await eventually(() => expect(attempts).toBe(2)) + // Accepted while the first send's join still runs, then that join comes back unproven. + const second = send('Second.') + joined.resolve(false) + + const secondId = await second + await eventually(async () => expect((await submission(first))?.dispatchState).toBe('rejected')) + await resumedWith(connection, 'Second.') + expect(await submission(secondId)).not.toMatchObject({ dispatchState: 'rejected' }) +}) + +it('runs the stop again for every ask after a close that came back unproven', async () => { + const connection = await stopWithUnprovenClose(3) + + await send('First.') + await eventually(() => expect(connection.closeCount).toBe(2)) + await laneDrained() + await send('Second.') + await eventually(() => expect(connection.closeCount).toBe(3)) + await laneDrained() + + // Each ask asked again; the third proves the exit. + await send('Third.') + await resumedWith(connection, 'Third.') + expect(connection.closeCount).toBe(4) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 6f7ef7ee7d1b..c92b23a49b9b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -27,7 +27,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const CALLER = { callerKey: 'client-1' } @@ -38,6 +38,7 @@ let codex: ReturnType let notify: (method: string, params: unknown) => void /** Read at each start, so a test can say what the next start resumes. */ let launch: { resumeThreadId?: string | null } +let log: ReturnType let disposeSession: MockInstance> beforeEach(async () => { @@ -70,7 +71,7 @@ beforeEach(async () => { }) disposeSession = vi.spyOn(adapter, 'disposeSession') host = new StructuredAgentSessionHost({ - logger: createStructuredAgentSessionLogger(), + logger: (log = recordingStructuredAgentSessionLogger()).logger, store, adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), @@ -533,4 +534,20 @@ describe('a message after a Codex Stop whose exit was unproven', () => { expect(startedWith(old, 'carry on')).toBe(false) expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ cause: 'user-stop' }) }) + + it('reports a process tree left unproven by a proven root exit, and ends the record anyway', async () => { + await runningTurn() + codex.routes['turn/interrupt'] = () => { + throw interruptFailure('internal error') + } + const old = codex.connections.at(-1)! + // The forced kill saw the root exit but could not prove the rest of the tree gone. + Object.defineProperty(old, 'processTreeUnproven', { get: () => old.closed }) + + await stop() + + await vi.waitFor(() => expect(host['sessions'].get(SESSION)?.child).toBeNull()) + expect(log.entries.map((entry) => entry.fields.scope)).toContain('provider-close-after-exit') + expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ rootGone: true }) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 3727040e3c0b..4f25268bc3b0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -115,16 +115,8 @@ export class StructuredAgentSessionDeliveryLoop { return } if (!prepared.ok) { - const { refusal, diagnostic } = prepared - // A conversation no agent ever ran, such as a cleared chat's, failed to start, not restart. - const newSession = this.deps.record(sessionId)?.providerHandleChain.length === 0 - const cause = { - refusal, - ...(diagnostic ? { diagnostic } : {}), - ...(newSession ? { newSession: true as const } : {}) - } await this.deps.serialize(sessionId, () => - this.fail(sessionId, { startKey: null, cause }) + this.fail(sessionId, this.refusedStart(sessionId, prepared)) ) return } @@ -187,6 +179,11 @@ export class StructuredAgentSessionDeliveryLoop { return this.fail(sessionId, failedStart) } const ready = await this.deps.ensureProviderChild(sessionId, oldest.clientMessageId) + if (!ready.ok && ready.refusal.details?.reason === 'previousExitUnverifiable') { + // Failed in the step that was refused: a message accepted, or an exit proven, after it must + // not be failed for a verdict that no longer holds. + return this.fail(sessionId, this.refusedStart(sessionId, ready)) + } if (!ready.ok) { return ready } @@ -254,6 +251,23 @@ export class StructuredAgentSessionDeliveryLoop { return 'continue' } + /** A start the session refused, as the failure every queued message it was for is rejected with. */ + private refusedStart( + sessionId: string, + { refusal, diagnostic }: Extract + ): StartFailure { + // A conversation no agent ever ran, such as a cleared chat's, failed to start, not restart. + const newSession = this.deps.record(sessionId)?.providerHandleChain.length === 0 + return { + startKey: null, + cause: { + refusal, + ...(diagnostic ? { diagnostic } : {}), + ...(newSession ? { newSession: true as const } : {}) + } + } + } + private async fail(sessionId: string, failure: StartFailure): Promise<'stop'> { const session = this.deps.sessions.get(sessionId) if (session) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 1a7742d3e5ff..398d04873d41 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -3,13 +3,18 @@ import type { StructuredAgentSessionLifecycleEvent } from './structured-agent-se import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' import type { StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' import { settleStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' -import { settleUnexpectedStructuredAgentSessionExit } from './structured-agent-session-unexpected-exit' -import { endClosedStructuredAgentSessionChild } from './structured-agent-session-child-close' -import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' +import { + endExitedStructuredAgentSessionChildUnderSerialize, + settleStructuredAgentSessionChildExit, + type StructuredAgentSessionChildExit, + type StructuredAgentSessionChildExitContext +} from './structured-agent-session-child-exit' +import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' export class StructuredAgentSessionEventRecovery { private readonly sinkFailures = new Set() @@ -24,19 +29,31 @@ export class StructuredAgentSessionEventRecovery { publishStatus?: (sessionId: string) => void serialize: (sessionId: string, task: () => Promise) => Promise now: () => number - /** Where a close the host asked for ends the child's record. */ - lifetime: () => StructuredAgentSessionLifetimeContext + runtimeState: StructuredAgentSessionHostRuntimeState + wakeDelivery: (sessionId: string) => void } ) {} - private get exitContext() { + private get exitContext(): StructuredAgentSessionChildExitContext { + const { deps, runtimeState } = this.context return { ...this.context, - logger: this.context.deps.logger, - wakeDelivery: this.context.lifetime().wakeDelivery + logger: deps.logger, + route: { + runtimeState, + acknowledgeRelease: (sessionId) => deps.adapter.acknowledgeSessionRelease?.(sessionId) + } } } + /** The one exit handler, for a caller inside the session's serialize that proved the exit. */ + endExitedChildUnderSerialize = ( + sessionId: string, + child: StructuredAgentSessionProviderChild, + exit: StructuredAgentSessionChildExit + ): Promise => + endExitedStructuredAgentSessionChildUnderSerialize(this.exitContext, sessionId, child, exit) + recoverAfterSinkFailure(sessionId: string, error: unknown): void { if (this.sinkFailures.has(sessionId)) { return @@ -47,26 +64,17 @@ export class StructuredAgentSessionEventRecovery { const child = this.context.sessions.get(sessionId)?.child const stop = this.context.deps.adapter.forceCloseSession ?? this.context.deps.adapter.closeSession - if (!child || !stop) { - return null - } - const { fence, generation: acquisitionGeneration } = child - const stopped = await stopAgentSessionProviderRoot(() => stop(sessionId)) - if (!stopped || !acquisitionGeneration) { - return null + if (!child || !stop || !(await stopAgentSessionProviderRoot(() => stop(sessionId)))) { + return } - return { - type: 'ended', - sessionId, + // Ended in the same step as the stop, so no report of that close can end it first as a + // quiet rest: Orca stopped the provider because its own journal failed. + await this.endExitedChildUnderSerialize(sessionId, child, { + expected: false, reason: `journal sink failure: ${error instanceof Error ? error.message : String(error)}`, - // Orca stopped the provider because its own journal failed. - failure: agentSessionFailureFact('hostFault'), - cause: 'unexpected-exit', - fence, - acquisitionGeneration - } as const + failure: agentSessionFailureFact('hostFault') + }) }) - .then((event) => (event ? this.handle(event) : undefined)) .catch((error: unknown) => this.context.deps.logger.warn( 'stopping a provider after its journal failed did not finish', @@ -87,9 +95,6 @@ export class StructuredAgentSessionEventRecovery { if (event.type === 'started') { return settleStructuredAgentSessionProviderStarted(this.context, event) } - if (event.cause === 'requested-close') { - return endClosedStructuredAgentSessionChild(this.context.lifetime(), event) - } - await settleUnexpectedStructuredAgentSessionExit(this.exitContext, event) + await settleStructuredAgentSessionChildExit(this.exitContext, event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts deleted file mode 100644 index 12b5316ef6f7..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts +++ /dev/null @@ -1,48 +0,0 @@ -// A bound on how long a caller waits on teardown, never on the teardown itself. -// -// Two uses. A caller joining a child's close waits this long for the exit's proof and then answers -// `unverifiable`; the close keeps running, and a proof that lands later still ends the record. Each -// wind-down step after the exit is bounded the same way, so one hung step is reported and the rest -// still run. - -import type { StructuredAgentSessionEvictionStep } from './structured-agent-session-eviction' - -export const STRUCTURED_AGENT_SESSION_EVICTION_STEP_TIMEOUT_MS = 10_000 - -export class StructuredAgentSessionEvictionTimeoutError extends Error { - constructor( - readonly step: string, - readonly timeoutMs: number - ) { - super(`agent session eviction step "${step}" did not finish within ${timeoutMs}ms`) - this.name = 'StructuredAgentSessionEvictionTimeoutError' - } -} - -export function withStructuredAgentSessionEvictionDeadline( - steps: readonly StructuredAgentSessionEvictionStep[], - timeoutMs = STRUCTURED_AGENT_SESSION_EVICTION_STEP_TIMEOUT_MS -): readonly StructuredAgentSessionEvictionStep[] { - return steps.map((step) => ({ - name: step.name, - run: async (context) => { - let timer: ReturnType | undefined - try { - await Promise.race([ - Promise.resolve(step.run(context)), - new Promise((_resolve, reject) => { - timer = setTimeout( - () => reject(new StructuredAgentSessionEvictionTimeoutError(step.name, timeoutMs)), - timeoutMs - ) - timer.unref?.() - }) - ]) - } finally { - if (timer) { - clearTimeout(timer) - } - } - } - })) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index 269ced39ac59..4cf0ad5ed447 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -9,7 +9,7 @@ import { StructuredAgentSessionHostRuntimeState } from './structured-agent-sessi import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' -function context(): StructuredAgentSessionEvictionContext & { order: string[] } { +function context(closeError?: Error): StructuredAgentSessionEvictionContext & { order: string[] } { const order: string[] = [] return { order, @@ -19,20 +19,19 @@ function context(): StructuredAgentSessionEvictionContext & { order: string[] } unbind: vi.fn(() => order.push('unbind')), drained: vi.fn(async () => { order.push('drained') - return { ok: true } + return { ok: true as const } }), - close: vi.fn(() => order.push('close')) - } as unknown as StructuredAgentSessionEvictionContext['eventSink'], + close: vi.fn(() => { + order.push('close') + if (closeError) { + throw closeError + } + }) + }, acknowledgeRelease: vi.fn(() => { order.push('acknowledgeRelease') }), - discardSink: vi.fn(() => order.push('discardSink')), - settleWork: vi.fn(async () => { - order.push('settleWork') - }), - releaseLease: vi.fn(async () => { - order.push('releaseLease') - }) + discardSink: vi.fn(() => order.push('discardSink')) } } @@ -45,29 +44,18 @@ function runtimeState(): StructuredAgentSessionHostRuntimeState { } as never) } -describe('the wind-down after a proven exit', () => { - it('drains what the child said before it lets the sink go, then acknowledges the release', async () => { +describe("the route release after a child's exit", () => { + it('lets the sink go before it acknowledges the release', async () => { const ctx = context() await evictStructuredAgentSession(ctx) - expect(ctx.order).toEqual([ - 'drained', - 'settleWork', - 'unbind', - 'close', - 'discardSink', - 'releaseLease', - 'acknowledgeRelease' - ]) + expect(ctx.order).toEqual(['unbind', 'close', 'discardSink', 'acknowledgeRelease']) }) it('names every step, so a failure says which one it was', () => { expect(STRUCTURED_AGENT_SESSION_EVICTION_STEPS.map((step) => step.name)).toEqual([ - 'drain-published', - 'settle-dead-generation', 'stop-publishing', 'close-sink', 'discard-sink', - 'release-lease', 'acknowledge-release' ]) }) @@ -75,30 +63,13 @@ describe('the wind-down after a proven exit', () => { // Bookkeeping for a process already gone: none of it may keep the child on record. it('reports a failed step with its name and still runs every step after it', async () => { const recorded = recordingStructuredAgentSessionLogger() - const ctx = { ...context(), logger: recorded.logger } - ctx.eventSink.drained = vi.fn(async () => { - ctx.order.push('drained') - return { ok: false, error: new Error('append failed') } - }) as unknown as StructuredAgentSessionEvictionContext['eventSink']['drained'] - ctx.releaseLease = vi.fn(async () => { - ctx.order.push('releaseLease') - throw new Error('store unavailable') - }) + const ctx = { ...context(new Error('sink already gone')), logger: recorded.logger } await expect(evictStructuredAgentSession(ctx)).resolves.toBeUndefined() - expect(ctx.order).toEqual([ - 'drained', - 'settleWork', - 'unbind', - 'close', - 'discardSink', - 'releaseLease', - 'acknowledgeRelease' - ]) + expect(ctx.order).toEqual(['unbind', 'close', 'discardSink', 'acknowledgeRelease']) expect(recorded.entries.map((entry) => entry.fields.error)).toEqual([ - expect.objectContaining({ step: 'drain-published' }), - expect.objectContaining({ step: 'release-lease' }) + expect.objectContaining({ step: 'close-sink' }) ]) expect(recorded.entries[0]?.fields.error).toBeInstanceOf(StructuredAgentSessionEvictionError) }) @@ -116,8 +87,6 @@ describe('eviction against the real sink cache', () => { logger: recordingStructuredAgentSessionLogger().logger, eventSink: state.eventSinkFor(sessionId), discardSink: () => state.discardEventSink(sessionId), - settleWork: async () => {}, - releaseLease: async () => {}, acknowledgeRelease: () => {} }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts index e0743e8fb028..2ecd885242c4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -1,16 +1,12 @@ -// What a host does once its provider child is gone: the bookkeeping that hands the session back. +// What a host does with a provider child's route once its exit has ended the child's record: the +// sink it wrote through and the adapter's index of it. Settling its work and handing the lease back +// are the exit handler's (`structured-agent-session-child-exit`); these come after. // // Teardown is a DATA list, not a method body, for the reason this file exists at all: the host // tracked which sessions were live in a map, and tore them down at three unrelated call sites // (app quit, handoff to a TUI, and error cleanup). Closing a chat was never wired to any of them, // so a provider child outlived the chat that owned it for the whole app session. // -// ORDER. These run only after the child's exit is proven (`structured-agent-session-child-close`). -// Closing it is not silent: the codex adapter emits its `ended` event and flushes coalesced text as -// part of shutting down, and those are the rows that clear the running-turn marker. Draining or -// closing the sink ahead of that drops them, which leaves the durable journal claiming the agent is -// still working. So: drain what the child emitted on its way out, settle, then let the sink go. -// // FAILURE. Each step is bookkeeping for a process already gone, so a step that fails is reported // and the rest still run: none of them may keep a dead child on record or gate the next send. @@ -20,18 +16,13 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo export type StructuredAgentSessionEvictionContext = { sessionId: string - eventSink: DeferredStructuredAgentSessionEventSink + eventSink: Pick logger: StructuredAgentSessionLogger /** Tells the adapter the released lease is done with, so it drops this child's route and index. * The conversation stays: stopping the agent never closes its journal. */ acknowledgeRelease: () => Promise | void /** Drops the cached sink so a later attach mints a fresh one. */ discardSink: () => void - /** Settles work owned by the child after its final callbacks have drained. */ - settleWork: () => Promise - /** Hands the lease back now that this host's child is proven gone. No-ops when the record is - * not this host's to release. */ - releaseLease: () => Promise } /** The resume offer is advisory; a stalled sink must not hold the child's stop behind it. */ @@ -61,16 +52,6 @@ export async function snapshotBeforeStructuredAgentSessionStop( export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSessionEvictionStep[] = [ - { - name: 'drain-published', - run: async (context) => { - const barrier = await context.eventSink.drained() - if (!barrier.ok) { - throw barrier.error - } - } - }, - { name: 'settle-dead-generation', run: (context) => context.settleWork() }, { name: 'stop-publishing', run: (context) => context.eventSink.unbind() }, { name: 'close-sink', run: (context) => context.eventSink.close() }, // Why: the runtime caches one sink per session id and hands the SAME instance to the next @@ -78,9 +59,6 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe // accepts every provider event and publishes none. Attach's own failure path already pairs // these two; eviction has to as well. { name: 'discard-sink', run: (context) => context.discardSink() }, - // The durable lease still names a process this host just proved gone; a start re-derives the - // release from that proof if this write fails. - { name: 'release-lease', run: (context) => context.releaseLease() }, { name: 'acknowledge-release', run: (context) => context.acknowledgeRelease() } ] 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 449c425fb69d..0c63f1fe4b48 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 @@ -9,7 +9,6 @@ // reentrant, so every public entry point takes it once and calls these. import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import { withTimeout } from '../../../shared/promise-timeout-fallback' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' @@ -21,14 +20,15 @@ import { joinStructuredAgentSessionChildClose } from './structured-agent-session import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' +import type { StructuredAgentSessionChildExit } from './structured-agent-session-child-exit' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' export type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-stop-event' import { recordStopEvent, - STOP_EVENT_DRAIN_TIMEOUT_MS, stopEndsWork, type StructuredAgentSessionStopEnding } from './structured-agent-session-host-stop-event' @@ -42,8 +42,13 @@ export type StructuredAgentSessionLifetimeContext = { publishStatus?: (sessionId: string) => void /** Hands the delivery loop what is queued; for a caller inside the session's serialize. */ wakeDelivery?: (sessionId: string) => void - /** The session's lane, which a close's late proof takes to end the child's record. */ - serialize: (sessionId: string, task: () => Promise) => Promise + /** The one exit handler (`structured-agent-session-child-exit`), for a caller inside the + * session's serialize that proved its child's exit. */ + endExitedChild: ( + sessionId: string, + child: StructuredAgentSessionProviderChild, + exit: StructuredAgentSessionChildExit + ) => Promise /** Quit-only snapshot taken immediately before the provider child is stopped. */ restartWitness?: { beforeStop: (sessionId: string) => void @@ -101,19 +106,22 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( if (!session || !child) { return } + const cause = 'recorded' in ending ? ending.recorded : ending.cause if (!child.close) { - // Judged before the kill: a stop that ends nothing writes nothing. + // Judged before the kill: a stop that ends nothing writes nothing. Its event is issued before + // the kill and never awaited by it; the journal writes rows in order. const recorded = (await stopEndsWork(context, sessionId, session, ending)) ? recordStopEvent(context, sessionId, session, ending) : Promise.resolve() child.close = { - cause: 'recorded' in ending ? ending.recorded : ending.cause, + cause, reason: ('reason' in ending ? ending.reason : undefined) ?? null, - recorded + recorded, + requestedAt: session.journal.cursor() } - // The event lands before the rows the kill makes the child write, bounded so a slow journal - // never holds the kill. - await withTimeout(recorded, STOP_EVENT_DRAIN_TIMEOUT_MS, undefined) + } else if (child.close.cause === cause) { + // The same stop asked again, such as a second close of the chat, closes what came since. + child.close.requestedAt = session.journal.cursor() } if (context.restartWitness) { await snapshotBeforeStructuredAgentSessionStop( @@ -125,10 +133,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( () => context.restartWitness?.beforeStop(sessionId) ) } - if ( - (await joinStructuredAgentSessionChildClose(context, sessionId, child, child.close)) !== - 'exited' - ) { + if ((await joinStructuredAgentSessionChildClose(context, sessionId, child)) !== 'exited') { throw new StructuredAgentSessionEvictionError( 'stop-provider-child', 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 2430884f6a1b..923faeb10e04 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 @@ -22,9 +22,8 @@ export type StructuredAgentSessionStopEnding = resting?: true } -/** How long a host stop waits for the session's sink before it judges whether the stop ends work, - * and for its event's write before the kill. */ -export const STOP_EVENT_DRAIN_TIMEOUT_MS = 1_000 +/** How long a host stop waits for the session's sink before it judges whether the stop ends work. */ +const STOP_EVENT_DRAIN_TIMEOUT_MS = 1_000 /** * Whether this stop ends work its event must record: a running turn or an unanswered send, a start's @@ -67,8 +66,8 @@ export async function stopEndsWork( ) } -/** Writes this stop's event (`JournalStopEvent`). Issued before the kill, which waits for it only - * `STOP_EVENT_DRAIN_TIMEOUT_MS`: bookkeeping, reported on failure. */ +/** Writes this stop's event (`JournalStopEvent`). Issued before the kill and never awaited by it: + * bookkeeping, reported on failure. */ export function recordStopEvent( context: StructuredAgentSessionLifetimeContext, sessionId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 068a047ffab4..ed82fffbe45c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -46,8 +46,9 @@ export type StructuredAgentSessionChildClose = { readonly reason: string | null /** The Stop event that stop wrote, folded before the work it ends is settled. */ readonly recorded: Promise - /** The adapter's close while it runs; cleared when it ends unproven, so the next ask retries. */ - attempt?: Promise + /** Where the journal stood when that stop was asked for: the child's end is ordered there, so a + * message accepted while the exit was being proven came after it. A repeated ask moves it. */ + requestedAt: AgentJournalCursor } /** The provider process behind a conversation. Written only in @@ -82,7 +83,7 @@ export type StructuredAgentSessionEndedChild = StructuredAgentSessionProviderChi duringStartup: boolean startedFor?: string /** Where the conversation's journal stood when the child ended, to order the end against a - * message's acceptance. */ + * message's acceptance. A close's end stands where its stop was asked for. */ endedAt: AgentJournalCursor } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index d98b338abfde..bdb82c8fadb1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -159,7 +159,8 @@ export class StructuredAgentSessionHost { publishStatus: this.clientDelivery.publishStatusAndSettlement, serialize: (sessionId, task) => this.tasks.trackAttach(this.serialize(sessionId, task)), now: () => this.now(), - lifetime: () => this.lifetimeContext() + runtimeState: this.runtimeState, + wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId) }) this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { ...structuredAgentSessionRestartResumeSurfaces(this, this.now), @@ -195,7 +196,7 @@ export class StructuredAgentSessionHost { now: () => this.now(), publishStatus: this.clientDelivery.publishStatus, wakeDelivery: (sessionId: string) => this.conversationDelivery.loop.wake(sessionId), - serialize: this.serialize + endExitedChild: this.eventRecovery.endExitedChildUnderSerialize } satisfies StructuredAgentSessionLifetimeContext } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts index fe584a50fcee..63e3add288e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts @@ -1,9 +1,9 @@ -// Handing the durable lease back after eviction stopped this host's child. +// Handing the durable lease back once this host's own provider child is gone. // -// Guarded on `hasProviderChild` for a reason that is not bookkeeping: a session restored only for -// reading, or one a TUI owns, names an owner process this host never started and may still be -// alive. Writing `exit-observed` against that record would release a lease out from under a running -// process and let a second writer in. +// Only an exit this host saw or proved, of the child it started at this fence, may release: a +// session restored only for reading, or one a TUI owns, names an owner process this host never +// started and may still be alive. Writing `exit-observed` against that record would release a lease +// out from under a running process and let a second writer in. import { isSurfaceReleasableAgentSessionRecord, @@ -17,45 +17,16 @@ export type StructuredAgentSessionLeaseStore = Pick< 'getRecord' | 'transitionHandoff' > -export async function releaseStoredStructuredAgentSessionOwner(input: { +/** Releases the lease of the child whose exit this host observed, with that exit's evidence. + * Throws `agent_session_checkpoint_stale` when the record no longer names that child. */ +export async function releaseStoredStructuredAgentSessionOwnerAfterExit(input: { store: StructuredAgentSessionLeaseStore sessionId: string - hasProviderChild: boolean expectedFence: number now: number -}): Promise { - if (!input.hasProviderChild) { - return - } - const record = input.store.getRecord(input.sessionId) - if ( - !record || - record.lease.runtimeFence !== input.expectedFence || - !isSurfaceReleasableAgentSessionRecord(record) - ) { - return - } - await releaseStoredAgentSessionOwnerAfterSurfaceClose(input.store, { - sessionId: input.sessionId, - expectedFence: input.expectedFence, - now: input.now - }) -} - -/** Releases only the exact provider child whose exit the adapter positively observed. */ -export async function releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExit(input: { - store: StructuredAgentSessionLeaseStore - sessionId: string - expectedFence: number - expectedAcquisitionGeneration: string - acquisitionGeneration: string | null - now: number exitObservedAt?: number exitReason?: string }): Promise { - if (input.acquisitionGeneration !== input.expectedAcquisitionGeneration) { - throw new Error('agent_session_checkpoint_stale') - } const record = input.store.getRecord(input.sessionId) if ( !record || @@ -68,7 +39,7 @@ export async function releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExi sessionId: input.sessionId, expectedFence: input.expectedFence, now: input.now, - exitObservedAt: input.exitObservedAt, + ...(input.exitObservedAt === undefined ? {} : { exitObservedAt: input.exitObservedAt }), ...(input.exitReason ? { exitReason: input.exitReason } : {}) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts index 2b84d4cfee23..d4236f395da4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts @@ -5,6 +5,7 @@ // an exit, a failed re-attach, a Stop and an eviction. Each is matched on the child's generation // and fence, so an ending that arrives late for an older child cannot end a newer one. +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { @@ -46,9 +47,12 @@ export function markProviderChildStarted( return child !== null } +/** `endedAt` is a close's ask; an exit of the child's own ends where the journal stands. */ export function endProviderChild( session: ChildBearer, - ended: Omit + ended: Omit & { + endedAt?: AgentJournalCursor + } ): boolean { const child = matchingChild(session, ended) if (!child) { @@ -58,7 +62,7 @@ export function endProviderChild( session.lastEndedChild = { ...ended, ...(child.startedFor === undefined ? {} : { startedFor: child.startedFor }), - endedAt: session.journal.cursor() + endedAt: ended.endedAt ?? session.journal.cursor() } return true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index 8235d0d67054..07f54bc2ab4a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -6,10 +6,10 @@ import { } from '../../../shared/agent-session-record.test-fixture' import { structuredAgentSessionCompactBody } from './structured-agent-session-command-turn' import { - settleUnexpectedStructuredAgentSessionExit, - type StructuredAgentSessionUnexpectedExitContext, - type StructuredAgentSessionUnexpectedExitSession -} from './structured-agent-session-unexpected-exit' + settleStructuredAgentSessionChildExit, + type StructuredAgentSessionChildExitContext, + type StructuredAgentSessionChildExitSession +} from './structured-agent-session-child-exit' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const SESSION = 'session-1' @@ -17,7 +17,7 @@ const GENERATION = 'generation-1' const REASON = 'claude stream-json exited (code 1): session limit reached' const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' -function startedSession(): StructuredAgentSessionUnexpectedExitSession & { +function startedSession(): StructuredAgentSessionChildExitSession & { journal: { appendLifecycleBatch: ReturnType } } { return { @@ -34,7 +34,7 @@ function startedSession(): StructuredAgentSessionUnexpectedExitSession & { } } -function contextFor(session: StructuredAgentSessionUnexpectedExitSession) { +function contextFor(session: StructuredAgentSessionChildExitSession) { let record: AgentSessionRecord = agentSessionRecordFixture( agentSessionLeaseFixture({ sessionId: SESSION, @@ -47,7 +47,7 @@ function contextFor(session: StructuredAgentSessionUnexpectedExitSession) { unreconciled: false }) ) - const context: StructuredAgentSessionUnexpectedExitContext = { + const context: StructuredAgentSessionChildExitContext = { logger: recordingStructuredAgentSessionLogger().logger, store: { getRecord: () => record, @@ -78,7 +78,7 @@ describe('a provider that ends before it finished starting', () => { it('tells the user why, even with no response in progress', async () => { const session = startedSession() - await settleUnexpectedStructuredAgentSessionExit(contextFor(session), { + await settleStructuredAgentSessionChildExit(contextFor(session), { ...ended, // The adapter typed the start's own failure; the host keeps it rather than reword it. failure: { kind: 'notSignedIn' }, @@ -106,7 +106,7 @@ describe('a provider that ends before it finished starting', () => { it('keeps an ordinary idle exit silent', async () => { const session = startedSession() - await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) + await settleStructuredAgentSessionChildExit(contextFor(session), ended) expect(session.child).toBeNull() expect(session.journal.appendLifecycleBatch).not.toHaveBeenCalled() @@ -118,7 +118,7 @@ describe('a provider that ends before it finished starting', () => { child: { generation: GENERATION, fence: 7, phase: 'starting' as const } } - await settleUnexpectedStructuredAgentSessionExit(contextFor(session), { + await settleStructuredAgentSessionChildExit(contextFor(session), { ...ended, failure: { kind: 'providerExited', detail: { text: REASON, audience: 'log' } } }) @@ -157,7 +157,7 @@ describe('a provider that ends before it finished starting', () => { } } - await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) + await settleStructuredAgentSessionChildExit(contextFor(session), ended) const text = 'Claude stopped before it finished starting. Run /compact again.' expect(session.journal.rejectPendingSubmissions).toHaveBeenCalledWith( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index 2654b20ac730..fb36ab2d5e09 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -118,6 +118,9 @@ async function mailTurn(): Promise { async function evictedAt(): Promise { let atClose: JournalStopEvent[] = [] rig.closeSession.mockImplementationOnce(async () => { + // The event is issued before the kill, never awaited by it: the journal writes it ahead of + // anything the kill makes the child write. + await new Promise((resolve) => setImmediate(resolve)) atClose = stopEvents() return true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index f220d5a0e489..1d7781c98869 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -45,6 +45,9 @@ function stopEvents(): JournalStopEvent[] { function stopEventsAtClose(): { events: JournalStopEvent[] | null } { const seen: { events: JournalStopEvent[] | null } = { events: null } rig.closeSession.mockImplementationOnce(async () => { + // The event is issued before the kill, never awaited by it: the journal writes it ahead of + // anything the kill makes the child write. + await new Promise((resolve) => setImmediate(resolve)) seen.events = stopEvents() return true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 4f4e414657de..e5222eae1b6a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -9,10 +9,10 @@ import { import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { - settleUnexpectedStructuredAgentSessionExit, - type StructuredAgentSessionUnexpectedExitContext, - type StructuredAgentSessionUnexpectedExitSession -} from './structured-agent-session-unexpected-exit' + settleStructuredAgentSessionChildExit, + type StructuredAgentSessionChildExitContext, + type StructuredAgentSessionChildExitSession +} from './structured-agent-session-child-exit' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const exitOutcome = (agent: string): string => @@ -105,7 +105,7 @@ describe('provider-exit settlement', () => { } } as unknown as StructuredAgentSessionHostSession - await settleUnexpectedStructuredAgentSessionExit( + await settleStructuredAgentSessionChildExit( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the exit settlement reads only these members of its context; the store double is partial. { logger: recordingStructuredAgentSessionLogger().logger, @@ -190,7 +190,7 @@ describe('provider-exit settlement', () => { transitionHandoff: async () => ({ lease: { runtimeFence: 8 } }) } - await settleUnexpectedStructuredAgentSessionExit( + await settleStructuredAgentSessionChildExit( { store, sessions: new Map([[SESSION, session]]), @@ -285,7 +285,7 @@ describe('provider-exit settlement', () => { epoch: 'epoch-1', sequence: 3 })) - const session: StructuredAgentSessionUnexpectedExitSession = { + const session: StructuredAgentSessionChildExitSession = { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), @@ -298,7 +298,7 @@ describe('provider-exit settlement', () => { } const { store } = mutableStore() - const context: StructuredAgentSessionUnexpectedExitContext = { + const context: StructuredAgentSessionChildExitContext = { logger: recordingStructuredAgentSessionLogger().logger, store, sessions: new Map([[SESSION, session]]), @@ -316,7 +316,7 @@ describe('provider-exit settlement', () => { serialize: async (_sessionId: string, task: () => Promise) => task(), now: () => 1_234 } - await settleUnexpectedStructuredAgentSessionExit(context, { + await settleStructuredAgentSessionChildExit(context, { type: 'ended', sessionId: SESSION, reason: 'provider exited after completing the turn', @@ -344,7 +344,7 @@ describe('provider-exit settlement', () => { it('settles a submission the dead child never acknowledged', async () => { const markPendingSubmissionsUnknown = vi.fn(async () => ['client-1']) - const session: StructuredAgentSessionUnexpectedExitSession = { + const session: StructuredAgentSessionChildExitSession = { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), @@ -358,7 +358,7 @@ describe('provider-exit settlement', () => { } const { store } = mutableStore() - const context: StructuredAgentSessionUnexpectedExitContext = { + const context: StructuredAgentSessionChildExitContext = { logger: recordingStructuredAgentSessionLogger().logger, store, sessions: new Map([[SESSION, session]]), @@ -367,7 +367,7 @@ describe('provider-exit settlement', () => { serialize: async (_sessionId: string, task: () => Promise) => task(), now: () => 1 } - await settleUnexpectedStructuredAgentSessionExit(context, { + await settleStructuredAgentSessionChildExit(context, { type: 'ended', sessionId: SESSION, reason: 'provider exited', @@ -397,7 +397,7 @@ describe('provider-exit settlement', () => { }) it('releases without offering a restart while terminal settlement is failing', async () => { - const session: StructuredAgentSessionUnexpectedExitSession = { + const session: StructuredAgentSessionChildExitSession = { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), @@ -423,7 +423,7 @@ describe('provider-exit settlement', () => { acquisitionGeneration: GENERATION } const { store } = mutableStore() - const context: StructuredAgentSessionUnexpectedExitContext = { + const context: StructuredAgentSessionChildExitContext = { store, sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: false, error: new Error('sink failed') }), @@ -432,7 +432,7 @@ describe('provider-exit settlement', () => { now: () => 1, logger: log.logger } - await settleUnexpectedStructuredAgentSessionExit(context, event) + await settleStructuredAgentSessionChildExit(context, event) expect(session.child).toBeNull() expect(publishFence).toHaveBeenCalledTimes(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts deleted file mode 100644 index 8db92939152e..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' -import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import { endProviderChild } from './structured-agent-session-provider-child' -import { - releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExit, - type StructuredAgentSessionLeaseStore -} from './structured-agent-session-lease-release' -import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' -import { - captureUnfinishedStructuredAgentSessionWork, - MAX_UNEXPECTED_EXIT_REASON_CHARS, - settleStructuredAgentSessionDeadGeneration, - type DeadGenerationJournal, - unfinishedStructuredAgentSessionWorkWasInterrupted -} from './structured-agent-session-dead-generation-settlement' -import type { StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' -import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' - -type UnexpectedExitLifecycleEvent = StructuredAgentSessionEndedEvent & { - cause: 'unexpected-exit' -} - -export type StructuredAgentSessionUnexpectedExitSession = Pick< - StructuredAgentSessionHostSession, - 'child' | 'lastEndedChild' -> & { journal: DeadGenerationJournal & Pick } - -export type StructuredAgentSessionUnexpectedExitContext< - TSession extends StructuredAgentSessionUnexpectedExitSession = StructuredAgentSessionHostSession -> = { - store: StructuredAgentSessionLeaseStore - sessions: Map - flushLifecycle: (sessionId: string) => Promise - publishFence: (sessionId: string, session: TSession) => void - publishStatus?: (sessionId: string) => void - /** The delivery loop hands over whatever is queued once the child is off the record. */ - wakeDelivery?: (sessionId: string) => void - serialize: (sessionId: string, task: () => Promise) => Promise - now: () => number - logger: StructuredAgentSessionLogger -} - -export async function settleUnexpectedStructuredAgentSessionExit< - TSession extends StructuredAgentSessionUnexpectedExitSession ->( - context: StructuredAgentSessionUnexpectedExitContext, - event: StructuredAgentSessionEndedEvent -): Promise { - if (event.cause !== 'unexpected-exit') { - return - } - const unexpectedEvent = event as UnexpectedExitLifecycleEvent - // Receipt of the exit is the one end time the host may record for a running turn. - const observedAt = event.observedAt ?? context.now() - return context.serialize(unexpectedEvent.sessionId, async () => { - const session = context.sessions.get(unexpectedEvent.sessionId) - const child = session?.child - if ( - !session || - !child || - child.fence !== unexpectedEvent.fence || - child.generation !== unexpectedEvent.acquisitionGeneration - ) { - return - } - // The host's own phase decides, so a provider that omits the flag still gets a start that - // failed told as one: the row says so. - const exitedDuringStartup = - unexpectedEvent.startupUnproven === true || child.phase === 'starting' - const endChild = (): void => { - endProviderChild(session, { - generation: child.generation, - fence: child.fence, - cause: 'exit', - reason: unexpectedEvent.reason, - ...(unexpectedEvent.failure ? { failure: unexpectedEvent.failure } : {}), - duringStartup: exitedDuringStartup, - // The adapter publishes an exit only once it saw the root go, first-hand or proven. - rootGone: true - }) - context.publishStatus?.(unexpectedEvent.sessionId) - } - const record = context.store.getRecord(unexpectedEvent.sessionId) - if (!record || record.lease.handoffStage !== null) { - // An acquisition or recovery already owns this lease's transition. - endChild() - return - } - - const stableSettlementId = providerExitSettlementId(unexpectedEvent) - const unfinishedWork = captureUnfinishedStructuredAgentSessionWork(session.journal) - try { - try { - const barrier = await context.flushLifecycle(unexpectedEvent.sessionId) - if (!barrier.ok) { - logExitFailure(context, unexpectedEvent, 'exit-lifecycle-barrier', barrier.error) - } - } catch (error) { - logExitFailure(context, unexpectedEvent, 'exit-lifecycle-barrier', error) - } - await retryUnexpectedExitSettlement({ - context, - event: unexpectedEvent, - journal: session.journal, - fence: child.fence, - stableSettlementId, - verdict: { state: 'interrupted', completedAt: observedAt }, - exitedDuringStartup, - failureTextContext: structuredAgentSessionFailureWordsContext(record, session.journal), - // A failed start always says why: no response was running to carry the reason. - showUnexpectedExitOutcome: - exitedDuringStartup || - unfinishedStructuredAgentSessionWorkWasInterrupted( - unfinishedWork, - session.journal, - observedAt - ) - }) - } finally { - // Provider exit was positively observed, so release the owner even when - // terminal settlement could not be durably accepted. - let released: Awaited< - ReturnType - > | null = null - try { - released = await releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExit({ - store: context.store, - sessionId: unexpectedEvent.sessionId, - expectedFence: unexpectedEvent.fence, - expectedAcquisitionGeneration: unexpectedEvent.acquisitionGeneration, - acquisitionGeneration: child.generation, - now: context.now(), - exitObservedAt: observedAt, - // Bare cause: whatever this settlement could not write is settled from it later (the - // settle recording it queues, or the next open or acquire); `exit-observed` says the rest. - exitReason: unexpectedEvent.reason.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS) - }) - } catch (error) { - logExitFailure(context, unexpectedEvent, 'exit-owner-release', error) - } finally { - endChild() - if (released) { - context.publishFence(unexpectedEvent.sessionId, session) - } - context.wakeDelivery?.(unexpectedEvent.sessionId) - } - } - }) -} - -async function retryUnexpectedExitSettlement(input: { - context: Pick - event: UnexpectedExitLifecycleEvent - journal: DeadGenerationJournal - fence: number - stableSettlementId: string - verdict: StructuredAgentSessionTurnVerdict - exitedDuringStartup: boolean - failureTextContext: AgentSessionFailureWordsContext - showUnexpectedExitOutcome?: boolean -}): Promise { - const settled = await settleStructuredAgentSessionDeadGeneration({ - journal: input.journal, - sessionId: input.event.sessionId, - fence: input.fence, - settlementId: input.stableSettlementId, - verdict: input.verdict, - pendingSubmissionReason: 'provider_exited_before_acknowledgement', - showUnexpectedExitOutcome: input.showUnexpectedExitOutcome, - ...(input.event.failure ? { exitFailure: input.event.failure } : {}), - failureTextContext: input.failureTextContext, - ...(input.exitedDuringStartup - ? { exitedDuringStartup: { generation: input.event.acquisitionGeneration } } - : {}) - }) - if (!settled.ok) { - logExitFailure(input.context, input.event, 'exit-settlement', settled.error) - } -} - -function logExitFailure( - context: Pick, - event: UnexpectedExitLifecycleEvent, - scope: string, - error: unknown -): void { - context.logger.warn('settling a provider exit did not finish', { - scope, - sessionId: event.sessionId, - error - }) -} - -function providerExitSettlementId(event: UnexpectedExitLifecycleEvent): string { - return `provider-exit:${event.sessionId}:${event.fence}:${event.acquisitionGeneration}` -} diff --git a/src/main/runtime/structured-agent-session-lifecycle-delivery.test.ts b/src/main/runtime/structured-agent-session-lifecycle-delivery.test.ts new file mode 100644 index 000000000000..b236c81d594b --- /dev/null +++ b/src/main/runtime/structured-agent-session-lifecycle-delivery.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { createStructuredAgentSessionLifecycleDelivery } from './structured-agent-session-lifecycle-delivery' + +function ended( + sessionId: string, + cause: 'unexpected-exit' | 'requested-close' +): StructuredAgentSessionLifecycleEvent { + return { + type: 'ended', + sessionId, + reason: 'exited', + cause, + fence: 1, + acquisitionGeneration: 'generation-1' + } +} + +describe('provider exits reaching the host', () => { + // A close the host asked for ends on its own session's lane; another chat's crash recovery, + // which can run a whole reacquisition, must not hold it up. + it('ends a requested close without waiting behind another chat in recovery', async () => { + const handled: string[] = [] + let releaseRecovery = (): void => {} + const recovery = new Promise((resolve) => { + releaseRecovery = resolve + }) + const delivery = createStructuredAgentSessionLifecycleDelivery({ + handle: async (event) => { + if (event.type === 'ended' && event.cause === 'unexpected-exit') { + await recovery + } + handled.push(event.sessionId) + }, + logger: recordingStructuredAgentSessionLogger().logger, + drainObservedExits: async () => {} + }) + + delivery.deliver(ended('crashed', 'unexpected-exit')) + delivery.deliver(ended('closed', 'requested-close')) + await new Promise((resolve) => setImmediate(resolve)) + + expect(handled).toEqual(['closed']) + releaseRecovery() + await delivery.drain() + expect(handled).toEqual(['closed', 'crashed']) + }) +}) diff --git a/src/main/runtime/structured-agent-session-lifecycle-delivery.ts b/src/main/runtime/structured-agent-session-lifecycle-delivery.ts index ccf026de359c..ba307c2cfb0a 100644 --- a/src/main/runtime/structured-agent-session-lifecycle-delivery.ts +++ b/src/main/runtime/structured-agent-session-lifecycle-delivery.ts @@ -3,8 +3,9 @@ // Exit recovery runs on one chain so teardown can drain it: exit callbacks arrive from child // process tasks, and a fire-and-forget one could otherwise append after the host flushed and // removed its journal directory. That chain orders nothing across sessions, and a recovery on it -// can run a whole reacquisition, so `started` stays off it: it takes only its own session's -// serialized step, and is tracked here so the same drain still waits for it. +// can run a whole reacquisition, so `started` and the end of a close the host asked for stay off +// it: each takes only its own session's serialized step, and is tracked here so the same drain +// still waits for it. import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' @@ -34,8 +35,8 @@ export function createStructuredAgentSessionLifecycleDelivery(input: { } return { deliver: (event) => { - if (event.type === 'started') { - // Called now, so the step is queued on its session ahead of any later exit of that child. + if (event.type === 'started' || event.cause === 'requested-close') { + // Called now, so the step is queued on its own session's lane, behind nothing of another's. const settling = settle(event) settlingStarts.add(settling) void settling.finally(() => settlingStarts.delete(settling)) From e97710fd8ae5f9ebd798a7a916c85a62b1894c0c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:39:47 -0700 Subject: [PATCH 006/269] fix(native-chat): an exit's lease release lands whatever the length of its reason A crash's reason can carry kilobytes of the provider's stderr, and a lease whose death detail is over 512 characters fails the store's own check. The exit handler cut it, but the release a start or the chat handle's close re-derives did not, so after a crash whose own release failed every message was refused as not resumable until restart. The record's builder now cuts the detail to the record's bound, so no writer can hand it one too long. --- .../structured-agent-session-child-exit.ts | 3 +-- ...ssion-claude-stop-exit-ends-record.test.ts | 22 +++++++++++++++++++ ...gent-session-surface-release-transition.ts | 10 +++++++-- src/shared/agent-session-record.ts | 4 +++- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index 8a470f209187..29fd78812f04 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -19,7 +19,6 @@ import { import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' import { captureUnfinishedStructuredAgentSessionWork, - MAX_UNEXPECTED_EXIT_REASON_CHARS, settleStructuredAgentSessionDeadGeneration, type DeadGenerationJournal, unfinishedStructuredAgentSessionWorkWasInterrupted @@ -182,7 +181,7 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< expectedFence: child.fence, now: context.now(), exitObservedAt: observedAt, - exitReason: exit.reason.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS) + exitReason: exit.reason }) released = true } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts index 91ac994d4a74..8c187c0f85ad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -387,6 +387,28 @@ it('sends after a proven exit whose resume-point write and lease release both fa await resumedWith(connection, 'Carry on.') }) +// A crash's reason can carry kilobytes of stderr; the release a start re-derives must still land. +it('sends after a crash with a long reason whose own lease release failed', async () => { + const connection = claude.connections[0]! + const transition = store.transitionHandoff.bind(store) + vi.spyOn(store, 'transitionHandoff') + .mockImplementationOnce(async () => { + throw new Error('store unavailable') + }) + .mockImplementation(transition) + + connection.handlers.onExit?.( + new Error(`claude stream-json exited (code 1): ${'stack frame\n'.repeat(700)}`) + ) + await eventually(() => expect(child()).toBeNull()) + await eventually(() => expect(scopes()).toContain('exit-owner-release')) + expect(lease()?.claimStatus).toBe('live') + + await send('Carry on.') + await resumedWith(connection, 'Carry on.') + expect(scopes()).not.toContain('ended-child-lease-release') +}) + it('rejects a message whose start meets a close still unverifiable, and starts nothing beside it', async () => { const connection = await stopWithUnprovenClose(2) diff --git a/src/main/runtime/agent-session-surface-release-transition.ts b/src/main/runtime/agent-session-surface-release-transition.ts index fe98a8705248..1099e8d6c045 100644 --- a/src/main/runtime/agent-session-surface-release-transition.ts +++ b/src/main/runtime/agent-session-surface-release-transition.ts @@ -9,7 +9,10 @@ // holding the stopped owner's fence is refused as stale rather than acting on its successor. import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' -import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + MAX_AGENT_SESSION_DEATH_DETAIL_CHARS, + type AgentSessionRecord +} from '../../shared/agent-session-record' import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import { assertFence, withLease } from './agent-session-lease-transitions' import type { AgentSessionRecordStore } from './agent-session-record-store' @@ -50,7 +53,10 @@ export function releaseAgentSessionOwnerAfterSurfaceClose(args: { lastRenewedAt: args.now, deathEvidence: { kind: 'exit-observed', - detail: args.exitReason ?? 'the last surface holding this session released it', + // A provider's exit reason can carry kilobytes of stderr; a longer detail fails the write. + detail: args.exitReason + ? args.exitReason.slice(0, MAX_AGENT_SESSION_DEATH_DETAIL_CHARS) + : 'the last surface holding this session released it', observedAt: args.exitObservedAt ?? args.now, ownerFence: record.lease.runtimeFence } diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index b8280cf332da..a9d563392f02 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -157,6 +157,8 @@ 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 @@ -290,7 +292,7 @@ function isAgentSessionDeathEvidence(value: unknown): value is AgentSessionDeath (evidence.kind === 'exit-observed' || evidence.kind === 'pid-absent' || evidence.kind === 'identity-mismatch') && - isBoundedString(evidence.detail, MAX_ID_LENGTH) && + isBoundedString(evidence.detail, MAX_AGENT_SESSION_DEATH_DETAIL_CHARS) && typeof observedAt === 'number' && Number.isSafeInteger(observedAt) && observedAt >= 0 && From ce52df90617828f284385b63aaff53d2afff2d58 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:40:06 -0700 Subject: [PATCH 007/269] fix(claude): a proven close waits at most 2 s for the output it already wrote Once the root's exit is proven, the close still waited for the SDK's output reader to end. Something outside the process tree that holds the output open would keep that close, and every send, Stop and quit joining it, waiting with no bound. The wait is now bounded; past it the close resolves as proven and the open output is logged. --- ...laude-stream-json-connection-close.test.ts | 49 +++++++++++++++++++ .../claude/claude-stream-json-connection.ts | 17 ++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/main/claude/claude-stream-json-connection-close.test.ts b/src/main/claude/claude-stream-json-connection-close.test.ts index f5bbc35d2698..aa4beb4eed96 100644 --- a/src/main/claude/claude-stream-json-connection-close.test.ts +++ b/src/main/claude/claude-stream-json-connection-close.test.ts @@ -4,6 +4,7 @@ import type { ChildProcessWithoutNullStreams } from 'node:child_process' import { describe, expect, it, vi } from 'vitest' import type { query } from '@anthropic-ai/claude-agent-sdk' import { + CLAUDE_READER_DRAIN_AFTER_EXIT_MS, openClaudeStreamJsonConnection, type ClaudeStreamJsonLaunch } from './claude-stream-json-connection' @@ -162,6 +163,54 @@ describe('Claude stream-json close ordering', () => { expect(next).toHaveBeenCalledOnce() }) + // Whatever holds the output open past a proven exit (a process that escaped the tree) must not + // keep every send and Stop that joins this close waiting. + it('resolves a proven close though the output never ends', async () => { + mocks.refresh.mockReset() + mocks.proveClaudeChildExit.mockReset() + mocks.refresh.mockResolvedValue(undefined) + mocks.proveClaudeChildExit.mockResolvedValue(true) + const child = fakeChild() + const next = vi.fn(() => new Promise>>(() => {})) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection. + const queryImpl = ((params: Parameters[0]) => { + params.options?.spawnClaudeCodeProcess?.({ + command: 'claude', + args: [], + env: {}, + signal: new AbortController().signal + }) + return { + [Symbol.asyncIterator]: () => ({ next }) + } + }) as unknown as typeof query + const connection = await openClaudeStreamJsonConnection( + { pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' }, + {}, + () => child, + queryImpl + ) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.useFakeTimers() + try { + let closed: boolean | undefined + void connection.close().then((value) => { + closed = value + }) + await vi.advanceTimersByTimeAsync(CLAUDE_READER_DRAIN_AFTER_EXIT_MS - 1) + expect(closed).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + expect(closed).toBe(true) + expect(warn).toHaveBeenCalledWith( + '[claude-stream-json] output still open after the proven exit:', + expect.anything() + ) + } finally { + vi.useRealTimers() + warn.mockRestore() + } + }) + it('reports a root that exits after its close came back unproven as that close ending', async () => { mocks.refresh.mockReset() mocks.proveClaudeChildExit.mockReset() diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index b7c50646fb46..d6511a998630 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -15,6 +15,7 @@ import { type ClaudeControlSurface } from './claude-agent-sdk-control-requests' import { createClaudeChildTreeReaper, proveClaudeChildExit } from './claude-agent-sdk-exit-proof' +import { withTimeout } from '../../shared/promise-timeout-fallback' import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' import { @@ -25,6 +26,10 @@ import type { ClaudeStructuredSdkOptions } from './claude-structured-launch-reso export { ClaudeControlRequestError } +/** How long a proven close waits for messages already written before the exit; whatever still + * holds the output open past it is no reason to keep the close unresolved. */ +export const CLAUDE_READER_DRAIN_AFTER_EXIT_MS = 2_000 + /** * The SDK is loaded at the structured-Claude boundary rather than by this module's * import. The ordinary runtime's class graph statically reaches this file, and the @@ -33,6 +38,7 @@ export { ClaudeControlRequestError } * path never opted into, and a missing SDK would fail runtime startup. Memoized, * so a session pays the import once per process rather than once per connection. */ + let claudeAgentSdk: Promise | null = null function loadClaudeAgentSdk(): Promise { @@ -332,7 +338,16 @@ export async function openClaudeStreamJsonConnection( closePromise = null return false } - await readerDone + const drained = await withTimeout( + readerDone.then(() => true), + CLAUDE_READER_DRAIN_AFTER_EXIT_MS, + false + ) + if (!drained) { + console.warn('[claude-stream-json] output still open after the proven exit:', { + pid: spawner.pid + }) + } return true })() return closePromise From 1d315b8e09b841050cf04c218bdfa28e52502968 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:40:21 -0700 Subject: [PATCH 008/269] fix(codex): an exit reported inside Orca's close keeps the reason Orca closed it for The connection reports the app-server's exit inside the close that ends it, so that report ended every Codex close and replaced the close's own reason (for example, a provider frame that could not be recorded) with the connection's stderr text in the ended record and the lease's exit evidence. The session now records Orca's close with its reason, and the exit it ends keeps that reason. The test connection reports its exit inside close the way the real one does. --- src/main/codex/codex-close-turn-end.test.ts | 1 - .../codex/codex-requested-close-turn-timing.test.ts | 1 - .../codex-structured-session-adapter-fixture.ts | 12 +++++++++--- .../codex/codex-structured-session-close.test.ts | 2 +- src/main/codex/codex-structured-session-close.ts | 12 ++++++++---- .../codex-structured-session-options-catalog.test.ts | 1 - .../codex/codex-structured-session-options.test.ts | 1 - src/main/codex/codex-structured-session-state.ts | 10 ++++------ 8 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/main/codex/codex-close-turn-end.test.ts b/src/main/codex/codex-close-turn-end.test.ts index 1e3475301bf0..43865f423758 100644 --- a/src/main/codex/codex-close-turn-end.test.ts +++ b/src/main/codex/codex-close-turn-end.test.ts @@ -58,7 +58,6 @@ function sessionWithRunningTurn() { }, backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, - requestedClose: false, fence: 7, acquisitionGeneration: 'generation-1', threadId: 'thread-1', diff --git a/src/main/codex/codex-requested-close-turn-timing.test.ts b/src/main/codex/codex-requested-close-turn-timing.test.ts index 45754f9f8042..5d110cb6e72c 100644 --- a/src/main/codex/codex-requested-close-turn-timing.test.ts +++ b/src/main/codex/codex-requested-close-turn-timing.test.ts @@ -53,7 +53,6 @@ describe('requested-close durable turn timing', () => { }, backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, - requestedClose: false, fence: 7, acquisitionGeneration: 'generation-1', threadId: 'thread-1', diff --git a/src/main/codex/codex-structured-session-adapter-fixture.ts b/src/main/codex/codex-structured-session-adapter-fixture.ts index f2b4e102c355..37d434ef1a41 100644 --- a/src/main/codex/codex-structured-session-adapter-fixture.ts +++ b/src/main/codex/codex-structured-session-adapter-fixture.ts @@ -50,7 +50,7 @@ export function fakeCodex(routes: Record = {}): { routes: Record } { const connections: FakeConnection[] = [] - const openConnection = (async (launch, handlers = {}) => { + const openConnection: typeof openCodexAppServerConnection = async (launch, handlers = {}) => { const connection: FakeConnection = { launch, handlers, @@ -67,15 +67,21 @@ export function fakeCodex(routes: Record = {}): { notify: () => {}, respond: (id, result) => connection.replies.push({ id, result }), respondWithError: (id, code, message) => connection.replies.push({ id, code, message }), + // As the real connection: the root's exit is reported, once, inside the close that ends it. close: async () => { connection.closeCount += 1 - connection.closed = true + if (!connection.closed) { + connection.closed = true + handlers.onExit?.(new Error('codex app-server connection ended: killed'), { + expected: true + }) + } return true } } connections.push(connection) return connection - }) as typeof openCodexAppServerConnection + } routes['thread/start'] ??= () => ({ thread: { id: THREAD_ID, path: '/rollouts/abc.jsonl' }, model: 'gpt-live', diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index 73004e43c344..79e4ea977a36 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -96,7 +96,7 @@ describe('Codex structured session close lifecycle', () => { connection, backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, - requestedClose, + ...(requestedClose ? { orcaClose: { requested: true, reason: new Error('closed') } } : {}), fence: 7, acquisitionGeneration: 'generation-1', threadId: THREAD, diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 3ab803101aa0..465d7801fec9 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -32,12 +32,13 @@ export function handleCodexSessionExit(input: { const event: StructuredAgentSessionEndedEvent = { type: 'ended', sessionId: input.sessionId, - reason: input.error.message, + // The connection reports the exit inside the close it ends; the close's own reason is the why. + reason: ((input.closedByOrca && session.orcaClose?.reason) || input.error).message, // Only the child's own exit blames Codex; a close Orca made, for any reason, is Orca's. failure: input.closedByOrca ? agentSessionFailureFact('hostFault') : agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(input.error) }), - cause: session.requestedClose ? 'requested-close' : 'unexpected-exit', + cause: session.orcaClose?.requested ? 'requested-close' : 'unexpected-exit', fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, observedAt: session.exitObservedAt @@ -92,7 +93,10 @@ export async function closeCodexPublishedSession( } // Sink-failure recovery force-closes the child but must preserve the // observed-exit cause so host lease settlement runs as an unexpected death. - session.requestedClose = options?.requestedClose ?? true + session.orcaClose = { + requested: options?.requestedClose ?? true, + reason: options?.unexpectedReason ?? new Error('codex session closed') + } // Keep the session indexed until the child exit is observed. A timeout or // failed kill must leave the live connection available for a safe retry. const exited = await session.connection.close() @@ -104,7 +108,7 @@ export async function closeCodexPublishedSession( sessions, sessionId, connection: session.connection, - error: options?.unexpectedReason ?? new Error('codex session closed'), + error: session.orcaClose.reason, closedByOrca: true, prompts: session.prompts, ...(onEvent ? { onEvent } : {}), diff --git a/src/main/codex/codex-structured-session-options-catalog.test.ts b/src/main/codex/codex-structured-session-options-catalog.test.ts index aac9e2946cf7..57c121c156dc 100644 --- a/src/main/codex/codex-structured-session-options-catalog.test.ts +++ b/src/main/codex/codex-structured-session-options-catalog.test.ts @@ -53,7 +53,6 @@ function storeSession( }, backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, - requestedClose: false, fence: 1, acquisitionGeneration: 'generation-1', threadId: 'thread-1', diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index d99ecc0305e1..e07f0b4c8847 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -27,7 +27,6 @@ function optionSession(request: CodexAppServerConnection['request']): CodexSessi }, backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, - requestedClose: false, fence: 1, acquisitionGeneration: 'generation-1', threadId: 'thread-1', diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 74246b6a7f17..78e7291fd742 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -108,7 +108,9 @@ export type CodexSession = { ended: boolean /** First observed child exit survives rejected settlement admission. */ exitObservedAt?: number - requestedClose: boolean + /** The close Orca began for this child: asked for, or forced as a death, and why. Whatever ends + * the child after it (that close, or the exit the connection reports meanwhile) keeps this. */ + orcaClose?: { requested: boolean; reason: Error } fence: number acquisitionGeneration: string threadId: string @@ -149,13 +151,9 @@ export function mintCodexAcquisitionGeneration(deps: CodexStructuredSessionAdapt export function codexSessionLifecycle( fence: number, acquisitionGeneration: string -): Pick< - CodexSession, - 'ended' | 'requestedClose' | 'fence' | 'acquisitionGeneration' | 'turnOpenWaits' -> { +): Pick { return { ended: false, - requestedClose: false, fence, acquisitionGeneration, turnOpenWaits: createCodexTurnOpenWaits() From 31819dbcb19645116635c8d33b42af79f29d97a2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:40:36 -0700 Subject: [PATCH 009/269] fix(native-chat): quit stops delivery before it drains exit recovery Every exit now wakes delivery, and teardown drained exit recovery before it stopped delivery, so an exit settled in that window could start a fresh agent that teardown then killed. Teardown stops delivery first; queued messages wait for the next launch. --- .../journal-host-database.test.ts | 3 ++- .../structured-agent-session-host.ts | 5 +++- ...red-agent-session-runtime-teardown.test.ts | 27 +++++++++++++++++++ ...ructured-agent-session-runtime-teardown.ts | 3 +++ 4 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 src/main/runtime/structured-agent-session-runtime-teardown.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts index 84ff9775022b..2b4e4a0819a7 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts @@ -232,8 +232,9 @@ describe('quit', () => { const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const database = openTestJournalHostDatabase(root) const installed = { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `flushAllStreamedEvents` on the host. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `stopDelivery` and `flushAllStreamedEvents` on the host. host: { + stopDelivery: () => {}, flushAllStreamedEvents: async () => { // A child's last row, delivered while quit is draining its sink. await new Promise((resolve) => setTimeout(resolve, 10)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index bdb82c8fadb1..3502a2dcae9c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -258,8 +258,11 @@ export class StructuredAgentSessionHost { // Trigger inlined rather than imported: `AgentSessionResumeTrigger` in shared is the canonical // type, and this file has no line budget left for the import. + /** Quit: no exit or recovery settled after this starts a child or hands a message over. */ + stopDelivery = (): void => this.conversationDelivery.dispose() + async flushAllStreamedEvents(options?: { trigger?: 'quit' | 'update' }): Promise { - this.conversationDelivery.dispose() + this.stopDelivery() await flushStructuredAgentSessionHost({ ...this.lifetimeContext(), idleSweep: this.lifetime, diff --git a/src/main/runtime/structured-agent-session-runtime-teardown.test.ts b/src/main/runtime/structured-agent-session-runtime-teardown.test.ts new file mode 100644 index 000000000000..767d0716c445 --- /dev/null +++ b/src/main/runtime/structured-agent-session-runtime-teardown.test.ts @@ -0,0 +1,27 @@ +import { expect, it } from 'vitest' +import { tearDownRuntime, type InstalledRuntime } from './structured-agent-session-runtime-teardown' + +// An exit that settles while teardown drains recovery wakes delivery; a fresh child started then +// would only be killed by the teardown after it. +it('stops delivery before it drains the recovery of exits already observed', async () => { + const order: string[] = [] + const installed: InstalledRuntime = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `stopDelivery` and `flushAllStreamedEvents` on the host. + host: { + stopDelivery: () => order.push('stop-delivery'), + flushAllStreamedEvents: async () => { + order.push('flush') + } + } as never, + adapter: { closeAll: async () => undefined }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `close` on the database. + journalDatabase: { close: () => order.push('close-database') } as never, + waitForRecovery: async () => { + order.push('wait-for-recovery') + } + } + + await tearDownRuntime(installed, 'quit') + + expect(order.slice(0, 2)).toEqual(['stop-delivery', 'wait-for-recovery']) +}) diff --git a/src/main/runtime/structured-agent-session-runtime-teardown.ts b/src/main/runtime/structured-agent-session-runtime-teardown.ts index 47d93e028307..3275939a4fa0 100644 --- a/src/main/runtime/structured-agent-session-runtime-teardown.ts +++ b/src/main/runtime/structured-agent-session-runtime-teardown.ts @@ -35,6 +35,9 @@ export async function tearDownRuntime( installed: InstalledRuntime, trigger: AgentSessionResumeTrigger ): Promise { + // An exit settled while recovery drains wakes delivery, which would start a fresh child for + // teardown to kill; queued messages wait for the next launch instead. + installed.host.stopDelivery() // Drain an in-flight recovery before stopping children; recovery may still // be writing lifecycle rows or acquiring a replacement child. await installed.waitForRecovery() From aa7928c29a069be814afebe513cd748b81edf933 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:40:46 -0700 Subject: [PATCH 010/269] docs(native-chat): the unverifiable-exit refusal no longer names a caller's wait The caller's bounded wait was removed; the comment describes the close as it is now. --- src/shared/agent-session-refusal-details.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 225a0861d46e..b56ca5fbeaaa 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -67,8 +67,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'notResumable', 'noProviderChild', 'conversationHeldElsewhere', - /** The close a stop began could not prove its child gone within the caller's wait: that child - * takes no input and none starts beside it. Sent with `ownerVerdict: 'unverifiable'`. */ + /** The close a stop began could not prove its child gone: that child takes no input and none + * starts beside it. Sent with `ownerVerdict: 'unverifiable'`. */ 'previousExitUnverifiable' ], agent_session_conflict: [ From 121150b0cd8362bed005dc9f0de42ca2c38c5c85 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:31:37 -0700 Subject: [PATCH 011/269] fix(native-chat): a stop whose kill did not take is logged, and the next ask kills again When a close's kill leaves the agent's root running, the host now logs it. Tests pin what a later ask does: each connection runs its whole stop again (Codex sends SIGKILL a second time), refuses input meanwhile, and proves the exit once the kill takes. --- ...laude-stream-json-connection-close.test.ts | 36 +++++++++++++++++++ .../codex/codex-app-server-connection.test.ts | 27 ++++++++++++++ .../structured-agent-session-child-close.ts | 28 ++++++++++----- 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/main/claude/claude-stream-json-connection-close.test.ts b/src/main/claude/claude-stream-json-connection-close.test.ts index aa4beb4eed96..348601bbd489 100644 --- a/src/main/claude/claude-stream-json-connection-close.test.ts +++ b/src/main/claude/claude-stream-json-connection-close.test.ts @@ -163,6 +163,42 @@ describe('Claude stream-json close ordering', () => { expect(next).toHaveBeenCalledOnce() }) + // The ladder (stdin end, SIGTERM, SIGKILL) runs again for the next ask; nothing reuses the root. + it('runs the stop ladder again on a close after an unproven attempt, refusing input meanwhile', async () => { + mocks.refresh.mockReset() + mocks.proveClaudeChildExit.mockReset() + mocks.refresh.mockResolvedValue(undefined) + mocks.proveClaudeChildExit.mockResolvedValueOnce(false).mockResolvedValueOnce(true) + const child = fakeChild() + const next = vi.fn(async (): Promise>> => ({ + value: undefined, + done: true + })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection. + const queryImpl = ((params: Parameters[0]) => { + params.options?.spawnClaudeCodeProcess?.({ + command: 'claude', + args: [], + env: {}, + signal: new AbortController().signal + }) + return { + [Symbol.asyncIterator]: () => ({ next }) + } + }) as unknown as typeof query + const connection = await openClaudeStreamJsonConnection( + { pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' }, + {}, + () => child, + queryImpl + ) + + await expect(connection.close()).resolves.toBe(false) + await expect(connection.send({ type: 'user' })).rejects.toThrow() + await expect(connection.close()).resolves.toBe(true) + expect(mocks.proveClaudeChildExit).toHaveBeenCalledTimes(2) + }) + // Whatever holds the output open past a proven exit (a process that escaped the tree) must not // keep every send and Stop that joins this close waiting. it('resolves a proven close though the output never ends', async () => { diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index 962270423e33..fa87062d724e 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -526,6 +526,33 @@ describe('openCodexAppServerConnection', () => { await expect(connection.close()).resolves.toBe(true) }) + // A root that outlived one kill is killed again by the next ask, which then proves it gone. + it('kills the root again on a close after an unproven attempt', async () => { + vi.useFakeTimers() + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + {}, + spawnImpl + ) + + const first = connection.close() + await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS + 3_500) + await expect(first).resolves.toBe(false) + child.kill.mockImplementation((signal) => { + if (signal === 'SIGKILL') { + setTimeout(() => child.emit('exit', null, 'SIGKILL'), 10) + } + return true + }) + + const second = connection.close() + await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS + 3_500) + await expect(second).resolves.toBe(true) + expect(child.kill.mock.calls.filter(([signal]) => signal === 'SIGKILL')).toHaveLength(2) + }) + it.each([1_090_188, 2_900_090])( 'accepts a realistic %i-byte escaped command completion and keeps processing', async (frameBytes) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts index d9232d142124..a8c08201394b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts @@ -77,14 +77,26 @@ function closeProviderRoot( sessionId, error }) - ).catch((error: unknown) => { - logger.warn("closing the agent's process did not prove it exited", { - scope: 'provider-close', - sessionId, - error - }) - return false - }) + ).then( + (exited) => { + if (!exited) { + // The adapter asked, then killed; the root is still there. The next ask kills again. + logger.warn("the agent's process did not exit after Orca stopped and killed it", { + scope: 'provider-close-unproven', + sessionId + }) + } + return exited + }, + (error: unknown) => { + logger.warn("closing the agent's process did not prove it exited", { + scope: 'provider-close', + sessionId, + error + }) + return false + } + ) } /** Whether the lease still names the child this host last proved gone, unreleased: only that From 1f4a19c1311e89748779225546e0f0bfdb4f81ef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:31:44 -0700 Subject: [PATCH 012/269] fix(native-chat): a start refused over the old process says Orca couldn't stop it The host reaches an unverifiable verdict only after its own kill left the agent's root running, on the machine that runs the agent, so the sentence now says that: "Orca couldn't stop {agent}'s previous process." The refusal reason, failure kind and wire shapes are unchanged. The host test also checks the failed kill is logged. --- ...ructured-agent-session-claude-stop-exit-ends-record.test.ts | 3 ++- .../structured-agent-session-codex-stop-row.test.ts | 3 +-- src/renderer/src/i18n/en-runtime-required.json | 2 +- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- src/shared/agent-session-failure-copy.ts | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts index 8c187c0f85ad..0aeac6c7832c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -417,7 +417,7 @@ it('rejects a message whose start meets a close still unverifiable, and starts n await eventually(async () => expect((await submission(next))?.dispatchState).toBe('rejected')) const rejected = (await submission(next))! expect(rejected.reason).toBe( - "Orca couldn't confirm Claude's previous process ended. Send your message to try again." + "Orca couldn't stop Claude's previous process. Send your message to try again." ) expect(rejected.rejection).toMatchObject({ kind: 'restartFailed', @@ -432,6 +432,7 @@ it('rejects a message whose start meets a close still unverifiable, and starts n expect(claude.connections).toHaveLength(1) expect(wrote(connection, 'Carry on.')).toBe(false) expect(child()?.close).toBeDefined() + expect(scopes()).toContain('provider-close-unproven') }) it('refuses an option change while the close stays unverifiable, never writing to the old child', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index c92b23a49b9b..a59f7eac346f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -497,8 +497,7 @@ describe('a message after a Codex Stop whose exit was unproven', () => { ) ).toMatchObject({ dispatchState: 'rejected', - reason: - "Orca couldn't confirm Codex's previous process ended. Send your message to try again." + reason: "Orca couldn't stop Codex's previous process. Send your message to try again." }) ) expect(codex.connections).toHaveLength(1) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 2ce3ec2b2420..b29a32458333 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2759,7 +2759,7 @@ "notDelivered": "This message was not delivered.", "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.", "notSignedIn": "{{agent}} is not signed in for the selected account.", - "previousExitUnverifiable": "Orca couldn't confirm {{agent}}'s previous process ended.", + "previousExitUnverifiable": "Orca couldn't stop {{agent}}'s previous process.", "providerExitedRejection": "{{agent}} stopped before this message was sent.", "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", "providerRateLimited": "{{agent}} is rate-limited and retrying.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9d0a16114491..ed1cb65313ad 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17561,7 +17561,7 @@ "providerRateLimited": "{{agent}} is rate-limited and retrying.", "providerRetrying": "{{agent}} hit a temporary problem and is retrying.", "providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.", - "previousExitUnverifiable": "Orca couldn't confirm {{agent}}'s previous process ended." + "previousExitUnverifiable": "Orca couldn't stop {{agent}}'s previous process." }, "writeNotice": { "notDoneReadHistory": "This chat's history couldn't be loaded.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 95d780a4ac47..977af682a035 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14999,7 +14999,7 @@ "providerRateLimited": "{{agent}} alcanzó un límite de solicitudes y está reintentando.", "providerRetrying": "{{agent}} tuvo un problema temporal y está reintentando.", "providerRetryingQuoted": "{{agent}} está reintentando: {{detail}}.", - "previousExitUnverifiable": "Orca no pudo confirmar que el proceso anterior de {{agent}} terminó." + "previousExitUnverifiable": "Orca no pudo detener el proceso anterior de {{agent}}." }, "writeNotice": { "notDoneReadHistory": "No se pudo cargar el historial de este chat.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 2d1a996c67db..8ee77c754f5c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17465,7 +17465,7 @@ "providerRateLimited": "{{agent}} est limité en débit et réessaie.", "providerRetrying": "{{agent}} a rencontré un problème temporaire et réessaie.", "providerRetryingQuoted": "{{agent}} réessaie : {{detail}}.", - "previousExitUnverifiable": "Orca n'a pas pu confirmer que le processus précédent de {{agent}} s'est arrêté." + "previousExitUnverifiable": "Orca n'a pas pu arrêter le processus précédent de {{agent}}." }, "writeNotice": { "notDoneReadHistory": "L'historique de ce chat n'a pas pu être chargé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a027e5e34e7d..12782e390ed6 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17401,7 +17401,7 @@ "providerRateLimited": "{{agent}} はレート制限を受けているため、再試行しています。", "providerRetrying": "{{agent}} で一時的な問題が発生したため、再試行しています。", "providerRetryingQuoted": "{{agent}} は再試行しています: {{detail}}。", - "previousExitUnverifiable": "Orca は {{agent}} の前のプロセスが終了したことを確認できませんでした。" + "previousExitUnverifiable": "Orca は {{agent}} の前のプロセスを停止できませんでした。" }, "writeNotice": { "notDoneReadHistory": "このチャットの履歴を読み込めませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 7c01fde23971..7b25344fe952 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17401,7 +17401,7 @@ "providerRateLimited": "{{agent}}이(가) 속도 제한에 걸려 다시 시도하고 있습니다.", "providerRetrying": "{{agent}}에 일시적인 문제가 발생하여 다시 시도하고 있습니다.", "providerRetryingQuoted": "{{agent}}이(가) 다시 시도하고 있습니다: {{detail}}.", - "previousExitUnverifiable": "Orca가 {{agent}}의 이전 프로세스가 종료되었는지 확인하지 못했습니다." + "previousExitUnverifiable": "Orca가 {{agent}}의 이전 프로세스를 중지하지 못했습니다." }, "writeNotice": { "notDoneReadHistory": "이 채팅의 기록을 불러오지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index bfb7bd46911c..721d1acae7b3 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17366,7 +17366,7 @@ "providerRateLimited": "{{agent}} 已被限流,正在重试。", "providerRetrying": "{{agent}} 遇到临时问题,正在重试。", "providerRetryingQuoted": "{{agent}} 正在重试:{{detail}}。", - "previousExitUnverifiable": "Orca 无法确认 {{agent}} 的上一个进程已结束。" + "previousExitUnverifiable": "Orca 无法停止 {{agent}} 的上一个进程。" }, "writeNotice": { "notDoneReadHistory": "无法加载此聊天的历史记录。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 04fa33c4b2bb..f40ffcfc1d2c 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -81,7 +81,7 @@ export const AGENT_SESSION_FAILURE_COPY = { providerRateLimited: '{{agent}} is rate-limited and retrying.', providerRetrying: '{{agent}} hit a temporary problem and is retrying.', providerRetryingQuoted: '{{agent}} is retrying: {{detail}}.', - previousExitUnverifiable: "Orca couldn't confirm {{agent}}'s previous process ended." + previousExitUnverifiable: "Orca couldn't stop {{agent}}'s previous process." } as const export type AgentSessionFailureCopyId = keyof typeof AGENT_SESSION_FAILURE_COPY From 54512de37bfbff44c3263d84e6c4d5999703b806 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:43:24 -0700 Subject: [PATCH 013/269] docs(native-chat): an unverifiable close verdict is a root that survived the kill The host's close runs where the agent runs, so lost contact never yields this verdict; the comment no longer says it does. --- .../structured-agent-session-child-close.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts index a8c08201394b..402c5677e9ef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts @@ -15,8 +15,8 @@ import { stopAgentSessionProviderRoot } from './structured-agent-session-provide import { releaseStoredStructuredAgentSessionOwnerAfterExit } from './structured-agent-session-lease-release' import { isSurfaceReleasableAgentSessionRecord } from '../../runtime/agent-session-surface-release-transition' -/** What a caller learned about the child's exit: proven, or not. A close that ran out of its own - * escalation, or a host Orca lost contact with, reads `unverifiable`, never `exited`. */ +/** What a caller learned about the child's exit: proven, or not. A root still there after the + * close's kill reads `unverifiable`, never `exited`. */ export type StructuredAgentSessionChildCloseVerdict = 'exited' | 'unverifiable' /** Joins the child's close and, once its root's exit is proven, ends the record. */ From 0a98f693d4342893c961ca0150631be88c3f244b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:48:41 -0700 Subject: [PATCH 014/269] fix(native-chat): a kill that did not take is reported once, by whoever met it The log added at the close fired beside a Stop's own failure report for the same event. A stop still reports it through its failure; a send or option change refused over it now logs it at the refusal, the only place it is otherwise invisible. --- .../structured-agent-session-child-close.ts | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts index 402c5677e9ef..0022df7c1565 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts @@ -53,9 +53,15 @@ export async function joinClosingStructuredAgentSessionChild( if (!child?.close) { return { ok: true } } - return (await joinStructuredAgentSessionChildClose(context, sessionId, child)) === 'exited' - ? { ok: true } - : { ok: false, refusal: previousExitUnverifiableRefusal() } + if ((await joinStructuredAgentSessionChildClose(context, sessionId, child)) === 'exited') { + return { ok: true } + } + // A stop reports this through its own failure; here the refusal is the only trace. + context.deps.logger.warn("the agent's process did not exit after Orca stopped and killed it", { + scope: 'provider-close-unproven', + sessionId + }) + return { ok: false, refusal: previousExitUnverifiableRefusal() } } function closeProviderRoot( @@ -77,26 +83,14 @@ function closeProviderRoot( sessionId, error }) - ).then( - (exited) => { - if (!exited) { - // The adapter asked, then killed; the root is still there. The next ask kills again. - logger.warn("the agent's process did not exit after Orca stopped and killed it", { - scope: 'provider-close-unproven', - sessionId - }) - } - return exited - }, - (error: unknown) => { - logger.warn("closing the agent's process did not prove it exited", { - scope: 'provider-close', - sessionId, - error - }) - return false - } - ) + ).catch((error: unknown) => { + logger.warn("closing the agent's process did not prove it exited", { + scope: 'provider-close', + sessionId, + error + }) + return false + }) } /** Whether the lease still names the child this host last proved gone, unreleased: only that From 1bf8f29e6543a59e15540963643bf2ed787edac5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:55:28 -0700 Subject: [PATCH 015/269] test(native-chat): a second Stop joins a close the first could not prove and retries its kill --- ...nt-session-claude-stop-exit-ends-record.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts index c0e11513490f..b0de80e26926 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -290,6 +290,20 @@ it('joins the close a Stop could not prove before the next message, then sends i }) }) +it('has a second Stop join the close the first could not prove, retrying its kill', async () => { + const connection = await stopWithUnprovenClose(1) + + // Nothing was queued, so it withdrew nothing and records no event of its own. + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: false } }) + expect(connection.closeCount).toBe(2) + expect(child()).toBeNull() + expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ + cause: 'user-stop', + rootGone: true + }) + expect(connection.calls.filter((call) => call.subtype === 'interrupt')).toHaveLength(1) +}) + it('has a send made while the close still runs wait for that close, not start another', async () => { const connection = claude.connections[0]! await openTurn(connection) From 7a8b2cb64d0602879df5ee7e4710b7cea39e32e6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:01:48 -0700 Subject: [PATCH 016/269] refactor(native-chat): keep the provider resume handle opaque to shared code Shared structured-chat code parsed each provider's resume handle: Claude's session id and branch leaf, Codex's thread id, through a 'claude' | 'codex' union every new agent had to widen. The in-memory handle is now { transport, agent, nativeId, providerData? }: shared readers use nativeId, lease and handle-chain checks compare transport and agent, and only the Claude adapter reads its leaf (providerData). Stored and wire forms are unchanged for Claude and Codex. One encoding module writes their typed shapes and decodes both those and the neutral shape a new transport uses, which an older build refuses as unreadable rather than reading as Codex. Key and root strings, which fork seeds, superseded creations and resume offers persist, stay byte-identical. The journal's own handle type becomes the journal-row and attach-wire encoding of the same handle, and the journal identity carries the neutral handle (null before the provider proves one). No user-visible change. --- .../journal-replay-retention-benchmark.mjs | 9 +- .../structured-session-ownership.test.ts | 10 +- .../claude-api-retry-idle-sweep.test.ts | 3 +- src/main/claude/claude-api-retry-row.test.ts | 3 +- .../claude-context-usage-restart.test.ts | 3 +- ...claude-context-usage-unloaded-turn.test.ts | 3 +- .../claude/claude-informational-row.test.ts | 3 +- .../claude-stream-json-connection.test.ts | 3 +- .../claude-structured-acquisition-launch.ts | 10 +- .../claude-structured-auth-parity.test.ts | 6 +- .../claude-structured-history-window.test.ts | 3 +- .../claude-structured-history-window.ts | 16 +- ...ude-structured-journal-translation.test.ts | 3 +- ...laude-structured-launch-resolution.test.ts | 26 +- .../claude-structured-launch-resolution.ts | 32 +- .../claude-structured-owner-identity.test.ts | 3 +- .../claude-structured-owner-identity.ts | 3 +- ...laude-structured-provider-fallback.test.ts | 3 +- .../claude-structured-real-cli-fold.test.ts | 3 +- .../claude/claude-structured-real-cli.test.ts | 13 +- .../claude-structured-resume-point.test.ts | 2 +- .../claude-structured-session-adapter.test.ts | 17 +- ...claude-structured-session-recovery.test.ts | 28 +- .../claude-structured-session-test-support.ts | 3 +- ...laude-structured-turn-resume-point.test.ts | 20 +- ...aude-subagent-resume-after-restart.test.ts | 3 +- .../codex-provider-retry-idle-sweep.test.ts | 3 +- ...-structured-acquisition-exit-proof.test.ts | 3 +- .../codex-structured-dispatch-test-support.ts | 3 +- ...x-structured-journal-goal-revision.test.ts | 3 +- ...tructured-journal-subagent-readers.test.ts | 3 +- ...journal-translation-turn-lifecycle.test.ts | 3 +- ...-journal-turn-settles-once-journal.test.ts | 3 +- ...codex-structured-launch-resolution.test.ts | 11 +- .../codex-structured-launch-resolution.ts | 3 +- .../codex-structured-owner-identity.test.ts | 3 +- .../codex/codex-structured-owner-identity.ts | 10 +- .../codex-structured-question-order.test.ts | 3 +- .../codex/codex-structured-session-acquire.ts | 10 +- ...odex-structured-session-adapter-fixture.ts | 3 +- ...ructured-session-adapter-lifecycle.test.ts | 3 +- .../codex-structured-session-adapter.test.ts | 7 +- ...tructured-session-background-tasks.test.ts | 3 +- .../codex-structured-session-cancel.test.ts | 3 +- .../codex-structured-session-close.test.ts | 3 +- .../codex-structured-session-shutdown.test.ts | 3 +- .../journal-corruption-repair.test.ts | 3 +- .../journal-crash-boundary.test.ts | 3 +- .../journal-dispatch-reason-bound.test.ts | 3 +- .../journal-epoch-replacement.test.ts | 5 +- .../journal-epoch-replacement.ts | 6 +- .../journal-epoch-rollover.ts | 6 +- .../journal-file-format-remnant.test.ts | 3 +- .../journal-host-database.test.ts | 5 +- .../journal-legacy-import.test.ts | 16 +- .../journal-per-session-import.test.ts | 3 +- .../journal-producer-inheritance.test.ts | 3 +- .../journal-restart-reconciliation.test.ts | 3 +- .../journal-row-builders.ts | 10 +- .../journal-row-context-usage.test.ts | 3 +- .../journal-row-schema-version.test.ts | 3 +- .../journal-row-schema.ts | 6 +- .../journal-stop-and-resume-rows.test.ts | 3 +- .../journal-store-schema.test.ts | 3 +- .../journal-store.test.ts | 3 +- .../agent-session-journal/journal-store.ts | 2 +- .../journal-subagent-liveness.test.ts | 3 +- .../journal-submission-queued-link.test.ts | 3 +- .../journal-unknown-row-kind.test.ts | 3 +- .../journal-write-queue.test.ts | 3 +- ...queued-message-bookkeeping-failure.test.ts | 3 +- .../queued-message-delivered-echo.test.ts | 3 +- .../queued-message-pause.test.ts | 3 +- .../queued-message-store.test.ts | 3 +- ...nt-session-history-byte-accounting.test.ts | 3 +- ...ession-history-conversation-window.test.ts | 3 +- ...ession-history-forward-read-budget.test.ts | 3 +- .../agent-session-history-page.test.ts | 3 +- .../agent-session-journal-recovery.test.ts | 14 +- .../agent-session-journal-recovery.ts | 12 +- ...-agent-session-accept-then-deliver.test.ts | 3 +- ...-agent-session-acquisition-options.test.ts | 7 +- ...tured-agent-session-adapter-router.test.ts | 5 +- ...tured-agent-session-adopted-import.test.ts | 3 +- ...structured-agent-session-adopted-import.ts | 11 +- ...ured-agent-session-append-delivery.test.ts | 3 +- ...red-agent-session-at-rest-commands.test.ts | 3 +- .../structured-agent-session-attach.ts | 40 +-- ...-agent-session-claude-option-queue.test.ts | 3 +- ...t-session-claude-stop-ends-session.test.ts | 2 +- ...ed-agent-session-conversation-stop.test.ts | 3 +- ...ctured-agent-session-conversations.test.ts | 3 +- ...tured-agent-session-crash-turn-end.test.ts | 9 +- ...session-dead-generation-settlement.test.ts | 5 +- ...tructured-agent-session-event-sink.test.ts | 3 +- ...ession-failed-create-owner-verdict.test.ts | 3 +- ...session-failed-create-sink-release.test.ts | 3 +- ...ctured-agent-session-forget-status.test.ts | 3 +- ...tured-agent-session-grouped-prompt.test.ts | 3 +- ...structured-agent-session-history-result.ts | 7 +- ...ed-agent-session-host-test-abandon.test.ts | 3 +- ...uctured-agent-session-host-test-harness.ts | 3 +- .../structured-agent-session-host.test.ts | 5 +- ...ured-agent-session-late-settlement.test.ts | 3 +- ...ctured-agent-session-lease-renewer.test.ts | 3 +- ...gent-session-legacy-handoff-record.test.ts | 3 +- ...ssion-main-agent-working-agreement.test.ts | 3 +- ...agent-session-operation-settlement.test.ts | 3 +- ...ed-agent-session-option-settlement.test.ts | 3 +- ...nt-session-processless-reservation.test.ts | 7 +- ...ctured-agent-session-prompt-cancel.test.ts | 3 +- ...gent-session-provider-child-record.test.ts | 5 +- ...red-agent-session-provider-restore.test.ts | 3 +- ...session-queued-message-rig.test-fixture.ts | 3 +- ...uctured-agent-session-read-restore.test.ts | 4 +- ...agent-session-recovered-turn-clock.test.ts | 3 +- ...tured-agent-session-recovery-exits.test.ts | 7 +- ...-agent-session-recovery-resolution.test.ts | 3 +- ...ctured-agent-session-refusal-retry.test.ts | 3 +- ...uctured-agent-session-repeat-press.test.ts | 3 +- ...ured-agent-session-resolved-append.test.ts | 3 +- .../structured-agent-session-rest-test-rig.ts | 3 +- ...ent-session-restart-resume-test-harness.ts | 8 +- ...session-restart-status-publication.test.ts | 3 +- ...ent-session-restore-without-import.test.ts | 3 +- .../structured-agent-session-reveal.test.ts | 3 +- ...tured-agent-session-rewind-at-rest.test.ts | 3 +- .../structured-agent-session-rewind.test.ts | 3 +- .../structured-agent-session-rewind.ts | 7 +- ...red-agent-session-send-idempotency.test.ts | 3 +- ...red-agent-session-send-preparation.test.ts | 3 +- ...session-send-restarts-failed-start.test.ts | 3 +- ...agent-session-settled-attach-retry.test.ts | 7 +- ...d-agent-session-stale-turn-verdict.test.ts | 11 +- ...agent-session-start-failure-writer.test.ts | 3 +- ...ent-session-status-feed-child-work.test.ts | 3 +- ...ed-agent-session-status-feed-child.test.ts | 3 +- ...agent-session-status-feed-children.test.ts | 3 +- ...ed-agent-session-status-feed-clock.test.ts | 3 +- ...ructured-agent-session-status-feed.test.ts | 3 +- ...tured-agent-session-status-reentry.test.ts | 3 +- ...red-agent-session-subagent-recency.test.ts | 3 +- ...ructured-agent-session-subscribers.test.ts | 24 +- ...red-agent-session-surface-lifetime.test.ts | 3 +- ...ructured-agent-session-thread-goal.test.ts | 3 +- .../structured-agent-session-turns.test.ts | 3 +- ...t-session-wedged-profile-migration.test.ts | 14 +- ...tured-agent-session-wire-admission.test.ts | 5 +- .../structured-conversation-command.test.ts | 20 +- ...structured-conversation-compaction.test.ts | 5 +- .../structured-provider-session-ownership.ts | 3 +- ...red-agent-session-history-adoption.test.ts | 12 +- ...ructured-agent-session-history-adoption.ts | 25 +- ...session-death-evidence-persistence.test.ts | 5 +- .../agent-session-lease-renewal.test.ts | 3 +- .../agent-session-lease-transitions.ts | 3 +- ...agent-session-legacy-record-import.test.ts | 28 +- .../agent-session-legacy-record-import.ts | 9 +- ...session-provider-handle-transition.test.ts | 43 ++- ...gent-session-provider-handle-transition.ts | 28 +- ...t-session-record-conversation-name.test.ts | 46 ++- .../agent-session-record-options.test.ts | 3 +- src/main/runtime/agent-session-record-rows.ts | 4 +- .../agent-session-record-store-file.ts | 4 +- ...agent-session-record-store-test-harness.ts | 26 +- .../agent-session-record-store.test.ts | 8 +- ...ession-released-reservation-replay.test.ts | 3 +- ...gent-session-reservation-admission.test.ts | 12 +- src/main/runtime/agent-session-store-draft.ts | 13 +- .../agent-session-store-transactions.test.ts | 3 +- ...sion-unsaved-creation-supersession.test.ts | 3 +- ...ude-structured-session-integration.test.ts | 14 +- .../structured-session-mail-address.ts | 6 +- .../structured-worker-journal-page.test.ts | 3 +- ...ured-agent-session-adoption-replay.test.ts | 3 +- .../structured-agent-session-hold.test.ts | 3 +- .../methods/structured-chat-tab-table.test.ts | 8 +- ...chestration-session-caller-test-fixture.ts | 7 +- ...untime-worktree-agent-rows-verdict.test.ts | 3 +- ...ree-structured-agent-rows-liveness.test.ts | 3 +- ...d-agent-session-integration-replay.test.ts | 3 +- ...ructured-agent-session-integration.test.ts | 5 +- ...gent-session-record-import-install.test.ts | 3 +- ...uctured-agent-session-runtime-exit.test.ts | 8 +- ...nt-session-spawn-token-descendants.test.ts | 3 +- ...-agent-session-startup-tab-restore.test.ts | 12 +- .../structured-chat-coordinator-mail.test.ts | 3 +- .../structured-claude-runtime-adapter.ts | 8 +- src/shared/agent-session-journal-types.ts | 13 +- ...agent-session-legacy-handoff-lease.test.ts | 16 +- .../agent-session-legacy-handoff-lease.ts | 19 +- .../agent-session-provider-handle-encoding.ts | 285 ++++++++++++++++++ .../agent-session-provider-handle.test.ts | 144 ++++++--- src/shared/agent-session-provider-handle.ts | 155 ++++++---- .../agent-session-record-stored-form.test.ts | 240 +++++++++++++++ .../agent-session-record-stored-form.ts | 41 +++ .../agent-session-record.test-fixture.ts | 3 +- src/shared/agent-session-record.ts | 15 +- ...x-child-approval-activity-row.unit.test.ts | 3 +- ...-session-stop-event-downgrade.unit.test.ts | 20 +- ...ss-version-agent-session-wire.unit.test.ts | 5 +- ...ured-agent-session-read-owner.unit.test.ts | 3 +- ...-chat-owner-status-activation.unit.test.ts | 3 +- 203 files changed, 1542 insertions(+), 632 deletions(-) create mode 100644 src/shared/agent-session-provider-handle-encoding.ts create mode 100644 src/shared/agent-session-record-stored-form.test.ts create mode 100644 src/shared/agent-session-record-stored-form.ts diff --git a/config/scripts/journal-replay-retention-benchmark.mjs b/config/scripts/journal-replay-retention-benchmark.mjs index ed3476702777..ecbc23538498 100644 --- a/config/scripts/journal-replay-retention-benchmark.mjs +++ b/config/scripts/journal-replay-retention-benchmark.mjs @@ -27,8 +27,10 @@ const identity = { workspaceId: 'fixture', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread' } + providerHandle: { transport: 'codex-app-server', agent: 'codex', nativeId: 'thread' } } +// The baseline predates the neutral handle and takes its journal identity in the typed form. +const baselineIdentity = { ...identity, providerHandle: { kind: 'codex', threadId: 'thread' } } const fixture = await mkdtemp(join(tmpdir(), 'orca-journal-replay-bench-')) // Released in `finally`, newest first: an open SQLite handle blocks the fixture's removal on Windows. const releases = [] @@ -75,7 +77,10 @@ async function openArm(arm) { let journal let database if (arm === 'baseline') { - journal = await implementation.openAgentSessionJournal({ identity, journalDir: stateDirectory }) + journal = await implementation.openAgentSessionJournal({ + identity: baselineIdentity, + journalDir: stateDirectory + }) } else { database = implementation.JournalHostDatabase.open(stateDirectory) releases.push(() => database.close()) diff --git a/src/main/ai-vault/structured-session-ownership.test.ts b/src/main/ai-vault/structured-session-ownership.test.ts index b7104e50a0cd..d363e47627f2 100644 --- a/src/main/ai-vault/structured-session-ownership.test.ts +++ b/src/main/ai-vault/structured-session-ownership.test.ts @@ -11,6 +11,10 @@ import { assertLegacyAiVaultResumeCommandAllowed, projectStructuredAiVaultSessions } from './structured-session-ownership' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../shared/agent-session-provider-handle-encoding' const PROVIDER_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' @@ -93,6 +97,7 @@ function installOwnership(overrides: Partial ...overrides } const record = agentSessionRecordFixture(ownership.lease) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ownership read touches only `deps.store.listRecords`; the rest of the host is never reached. setStructuredAgentSessionHost({ deps: { store: { @@ -105,7 +110,10 @@ function installOwnership(overrides: Partial providerHandleChain: [ { ...record.providerHandleChain[0]!, - handle: { provider: ownership.provider, threadId: ownership.providerSessionId } + handle: + ownership.provider === 'claude' + ? claudeProviderHandle(ownership.providerSessionId, null) + : codexProviderHandle(ownership.providerSessionId) } ], lease: { ...ownership.lease, sessionId: ownership.sessionId } diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index dcbd00776fda..695acefbb6f8 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -19,6 +19,7 @@ import { import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SWEEP_MS = 5 const RETRY_GAP_MS = 10 * 60_000 @@ -63,7 +64,7 @@ beforeEach(async () => { acquisitionGeneration: 'generation-1', link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/claude/claude-api-retry-row.test.ts b/src/main/claude/claude-api-retry-row.test.ts index 843fbb281d3d..f2933b8a702b 100644 --- a/src/main/claude/claude-api-retry-row.test.ts +++ b/src/main/claude/claude-api-retry-row.test.ts @@ -13,13 +13,14 @@ import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session- import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'workspace-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: 'leaf-1' } + providerHandle: claudeProviderHandle('provider-1', 'leaf-1') } /** A frame as Claude Code sends it while it retries a refused request. */ diff --git a/src/main/claude/claude-context-usage-restart.test.ts b/src/main/claude/claude-context-usage-restart.test.ts index be2c1569eadf..33ce088cca0f 100644 --- a/src/main/claude/claude-context-usage-restart.test.ts +++ b/src/main/claude/claude-context-usage-restart.test.ts @@ -11,6 +11,7 @@ import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { bindClaudeContextUsageCapture } from './claude-context-usage' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const journals = createTrackedJournalOpener() let root: string @@ -104,7 +105,7 @@ async function openJournal(): Promise { workspaceId: 'workspace-1', hostId: 'local', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } + providerHandle: claudeProviderHandle('claude-session', null) }, now: () => 9_000, stateDirectory: join(root, 'orca-session') diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index d98ef7583df5..5473719ce114 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -28,6 +28,7 @@ import { } from './claude-context-usage-test-support' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION = 'orca-session' const journals = createTrackedJournalOpener() @@ -49,7 +50,7 @@ async function openJournal(): Promise { workspaceId: 'workspace-1', hostId: 'local', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } + providerHandle: claudeProviderHandle('claude-session', null) }, now: () => 9_000, stateDirectory: join(root, SESSION) diff --git a/src/main/claude/claude-informational-row.test.ts b/src/main/claude/claude-informational-row.test.ts index e41b71e1a2c1..b40d16b633bf 100644 --- a/src/main/claude/claude-informational-row.test.ts +++ b/src/main/claude/claude-informational-row.test.ts @@ -8,13 +8,14 @@ import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/ag import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'workspace-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: 'leaf-1' } + providerHandle: claudeProviderHandle('provider-1', 'leaf-1') } /** A frame as Claude Code 2.1.280 sends it: a transcript note at a render level. */ diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 082ed4c12ce7..bd79a4a2c9d2 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -22,6 +22,7 @@ import type { ClaudeSession } from './claude-structured-session-state' import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' // These drive the real SDK against the scripted fake CLI, so every assertion is // about the environment, argv and frames a real child actually saw. @@ -380,7 +381,7 @@ describe('Claude stream-json connection', () => { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: SESSION_ID, leafUuid: 'leaf-1' } + providerHandle: claudeProviderHandle(SESSION_ID, 'leaf-1') }, database: openTestJournalHostDatabase(join(scenario.cwd, 'journal')), now: () => 1_700_000_000_000, diff --git a/src/main/claude/claude-structured-acquisition-launch.ts b/src/main/claude/claude-structured-acquisition-launch.ts index b3764b16d99e..4de526c7c4db 100644 --- a/src/main/claude/claude-structured-acquisition-launch.ts +++ b/src/main/claude/claude-structured-acquisition-launch.ts @@ -1,3 +1,4 @@ +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import { AgentSessionAcquisitionExitUnprovenError, AgentSessionPreSpawnError, @@ -72,11 +73,10 @@ export async function resolveClaudeAcquisitionLaunch(args: { const launchIdentity = resumeSession ? { ...input.identity, - providerHandle: { - kind: 'claude' as const, - sessionId: resumeSession.providerSessionId, - leafUuid: resumeSession.turnEndLeafUuid - } + providerHandle: claudeProviderHandle( + resumeSession.providerSessionId, + resumeSession.turnEndLeafUuid + ) } : input.identity const launch = await deps diff --git a/src/main/claude/claude-structured-auth-parity.test.ts b/src/main/claude/claude-structured-auth-parity.test.ts index 002240e4dd77..aa535a12a321 100644 --- a/src/main/claude/claude-structured-auth-parity.test.ts +++ b/src/main/claude/claude-structured-auth-parity.test.ts @@ -15,6 +15,7 @@ import { fakeClaude, identityFor } from './claude-structured-session-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'orca-session-auth' const IDENTITY = { sessionId: SESSION_ID } as Parameters< @@ -60,11 +61,10 @@ function realResolverAdapter( claude: ReturnType, authSwitchSettleTimeoutMs: number ): ClaudeStructuredSessionAdapter { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only the chain head's handle from this partial record. const resumable = { ...record(), - providerHandleChain: [ - { handle: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } } - ] + providerHandleChain: [{ handle: claudeProviderHandle(PROVIDER_SESSION_ID, null) }] } as unknown as AgentSessionRecord return new ClaudeStructuredSessionAdapter({ resolveLaunch: createClaudeStructuredLaunchResolver({ diff --git a/src/main/claude/claude-structured-history-window.test.ts b/src/main/claude/claude-structured-history-window.test.ts index 851831b86f19..e5ed29561cbc 100644 --- a/src/main/claude/claude-structured-history-window.test.ts +++ b/src/main/claude/claude-structured-history-window.test.ts @@ -13,6 +13,7 @@ import { claudeProviderHistoryWindowFromJsonl, resolveClaudeProviderHistoryWindow } from './claude-structured-history-window' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const PROVIDER_SESSION = 'provider-1' const ORCA_SESSION = 'session-1' @@ -82,7 +83,7 @@ describe('claudeProviderHistoryWindowFromJsonl', () => { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION, leafUuid: 'anchor' } + providerHandle: claudeProviderHandle(PROVIDER_SESSION, 'anchor') }, accountHomePath: accountHome, hasLiveSession: false diff --git a/src/main/claude/claude-structured-history-window.ts b/src/main/claude/claude-structured-history-window.ts index 00ae86920251..fc5c661a6906 100644 --- a/src/main/claude/claude-structured-history-window.ts +++ b/src/main/claude/claude-structured-history-window.ts @@ -16,6 +16,11 @@ import { join } from 'node:path' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { + CLAUDE_STRUCTURED_HANDLE_NAMESPACE, + claudeProviderHandleLeafUuid, + isAgentSessionProviderHandleInNamespace +} from '../../shared/agent-session-provider-handle-encoding' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import type { ProviderHistoryItem, @@ -242,10 +247,13 @@ export async function resolveClaudeProviderHistoryWindow(input: { hasLiveSession: boolean }): Promise { const handle = input.identity.providerHandle - if (handle.kind !== 'claude') { + if ( + !handle || + !isAgentSessionProviderHandleInNamespace(handle, CLAUDE_STRUCTURED_HANDLE_NAMESPACE) + ) { return null } - const transcriptPath = await resolveSessionFilePath('claude', handle.sessionId, { + const transcriptPath = await resolveSessionFilePath('claude', handle.nativeId, { claudeProjectsDir: join(input.accountHomePath, 'projects') }) if (!transcriptPath) { @@ -253,8 +261,8 @@ export async function resolveClaudeProviderHistoryWindow(input: { } return readClaudeProviderHistoryWindow({ transcriptPath, - providerSessionId: handle.sessionId, - previousLeafUuid: handle.leafUuid, + providerSessionId: handle.nativeId, + previousLeafUuid: claudeProviderHandleLeafUuid(handle), sessionId: input.identity.sessionId, turnInFlight: input.hasLiveSession }) diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 0cf7b30368a7..4aa02ed525cb 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -24,6 +24,7 @@ import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' function sinkState() { const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] @@ -180,7 +181,7 @@ const JOURNAL_IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: 'leaf-1' } + providerHandle: claudeProviderHandle('claude-session', 'leaf-1') } let journalRoot = '' diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 66fed64c3230..9db732d3430d 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -16,6 +16,7 @@ import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'orca-session-1' const IDENTITY = { sessionId: SESSION_ID } as Parameters< @@ -41,7 +42,7 @@ function record(overrides: Partial = {}): AgentSessionRecord function identityAt(leafUuid: string | null): typeof IDENTITY { return { ...IDENTITY, - providerHandle: { kind: 'claude', sessionId: 'provider-current', leafUuid } + providerHandle: claudeProviderHandle('provider-current', leafUuid) } } @@ -100,8 +101,9 @@ const WSL_ONLY_NORMALIZED: ClaudeManagedAccountGateSettings = { } const RESUMABLE = record({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset. providerHandleChain: [ - { handle: { provider: 'claude', sessionId: 'provider-current', leafUuid: 'leaf-current' } } + { handle: claudeProviderHandle('provider-current', 'leaf-current') } ] as AgentSessionRecord['providerHandleChain'] }) @@ -136,14 +138,11 @@ describe('claude structured launch resolution', () => { it('resumes the durable chain head by session id and carries its leaf as bookkeeping', async () => { const launch = await resolverFor( record({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset. providerHandleChain: [ - { handle: { provider: 'claude', sessionId: 'provider-old', leafUuid: 'leaf-old' } }, + { handle: claudeProviderHandle('provider-old', 'leaf-old') }, { - handle: { - provider: 'claude', - sessionId: 'provider-current', - leafUuid: 'leaf-current' - } + handle: claudeProviderHandle('provider-current', 'leaf-current') } ] as AgentSessionRecord['providerHandleChain'] }) @@ -194,7 +193,7 @@ describe('claude structured launch resolution', () => { resolve({ identity: { ...IDENTITY, - providerHandle: { kind: 'claude', sessionId: 'provider-other', leafUuid: 'leaf-current' } + providerHandle: claudeProviderHandle('provider-other', 'leaf-current') } }) ).rejects.toThrow('durable resume identity changed before spawn') @@ -203,13 +202,10 @@ describe('claude structured launch resolution', () => { it('keeps session-only resume when the durable handle has no leaf', async () => { const launch = await resolverFor( record({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset. providerHandleChain: [ { - handle: { - provider: 'claude', - sessionId: 'provider-current', - leafUuid: null - } + handle: claudeProviderHandle('provider-current', null) } ] as AgentSessionRecord['providerHandleChain'] }) @@ -226,7 +222,7 @@ describe('claude structured launch resolution', () => { record({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle. providerHandleChain: [ - { handle: { provider: 'claude', sessionId: 'provider-current', leafUuid: null } } + { handle: claudeProviderHandle('provider-current', null) } ] as AgentSessionRecord['providerHandleChain'] }), undefined, diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 29703dfd8eb7..979b5ae5963e 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -5,7 +5,11 @@ import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' +import { + agentSessionProviderHandleChainHead, + agentSessionProviderHandleRoot +} from '../../shared/agent-session-provider-handle' +import { claudeProviderHandleLeafUuid } from '../../shared/agent-session-provider-handle-encoding' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' @@ -260,24 +264,25 @@ export function createClaudeStructuredLaunchResolver( gate && hasWslBoundClaudeAccount(gate) ? { reason: 'managedAccountUnsupported' } : {} ) } - const head = agentSessionProviderHandleChainHead(record.providerHandleChain) + // A Claude record's chain holds only Claude handles; the record store refuses anything else. + const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle ?? null if ( - head?.handle.provider === 'claude' && - (identity.providerHandle.kind !== 'claude' || - identity.providerHandle.sessionId !== head.handle.sessionId) + head && + (!identity.providerHandle || + agentSessionProviderHandleRoot(identity.providerHandle) !== + agentSessionProviderHandleRoot(head)) ) { throw new Error('claude durable resume identity changed before spawn') } - const providerSessionId = - head?.handle.provider === 'claude' - ? head.handle.sessionId - : claudeSessionIdForOrcaSession(identity.sessionId) - const continuesChain = head?.handle.provider === 'claude' + const providerSessionId = head + ? head.nativeId + : claudeSessionIdForOrcaSession(identity.sessionId) + const continuesChain = head !== null // A start that failed before its first turn wrote no transcript, and `--resume` of an absent // one exits; launch that id fresh instead. With a transcript, `--session-id` would collide. const resumesTranscript = - head?.handle.provider === 'claude' && - (head.handle.leafUuid !== null || + head !== null && + (claudeProviderHandleLeafUuid(head) !== null || (await (deps.hasTranscript ?? claudeTranscriptExists)({ providerSessionId, claudeConfigDir: record.accountHome.path @@ -308,8 +313,7 @@ export function createClaudeStructuredLaunchResolver( env, claudeConfigDir: record.accountHome.path, providerSessionId, - resumeLeafUuid: - resumesTranscript && head?.handle.provider === 'claude' ? head.handle.leafUuid : null, + resumeLeafUuid: resumesTranscript && head ? claudeProviderHandleLeafUuid(head) : null, resumesTranscript, continuesChain } diff --git a/src/main/claude/claude-structured-owner-identity.test.ts b/src/main/claude/claude-structured-owner-identity.test.ts index 592b61df3380..729aecbec877 100644 --- a/src/main/claude/claude-structured-owner-identity.test.ts +++ b/src/main/claude/claude-structured-owner-identity.test.ts @@ -1,12 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const IDENTITY = { sessionId: 'session-identity', workspaceId: 'workspace-1', hostId: 'local', agent: 'claude' as const, - providerHandle: { kind: 'claude' as const, sessionId: 'session-1', leafUuid: 'leaf-1' } + providerHandle: claudeProviderHandle('session-1', 'leaf-1') } describe('claude structured owner identity', () => { diff --git a/src/main/claude/claude-structured-owner-identity.ts b/src/main/claude/claude-structured-owner-identity.ts index e8f251d41f91..80f6ca6977dc 100644 --- a/src/main/claude/claude-structured-owner-identity.ts +++ b/src/main/claude/claude-structured-owner-identity.ts @@ -1,5 +1,6 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' import { readProcessStartTimeMs } from '../runtime/agent-session-process-identity-probe' @@ -16,7 +17,7 @@ export function claudeProviderHandleLink(input: { linkId: input.linkId ?? `claude-${input.fence}-${input.sessionId}-${input.leafUuid ?? 'empty'}`.slice(0, 128), - handle: { provider: 'claude', sessionId: input.sessionId, leafUuid: input.leafUuid }, + handle: claudeProviderHandle(input.sessionId, input.leafUuid), origin: input.origin ?? (input.resumed ? 'resumed' : 'created'), mintedAtFence: input.fence, observedAt: input.observedAt diff --git a/src/main/claude/claude-structured-provider-fallback.test.ts b/src/main/claude/claude-structured-provider-fallback.test.ts index 4cae8b465e99..3e67d35f7b48 100644 --- a/src/main/claude/claude-structured-provider-fallback.test.ts +++ b/src/main/claude/claude-structured-provider-fallback.test.ts @@ -12,13 +12,14 @@ import { createClaudeJournalTranslator } from './claude-structured-journal-trans import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'workspace-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: 'leaf-1' } + providerHandle: claudeProviderHandle('provider-1', 'leaf-1') } let root = '' diff --git a/src/main/claude/claude-structured-real-cli-fold.test.ts b/src/main/claude/claude-structured-real-cli-fold.test.ts index 5ce21d83f170..13e4ee5e6720 100644 --- a/src/main/claude/claude-structured-real-cli-fold.test.ts +++ b/src/main/claude/claude-structured-real-cli-fold.test.ts @@ -27,6 +27,7 @@ import { realClaudeCommand, realClaudeLaunchHome } from './claude-real-cli-availability-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'real-cli-fold' // Pins the live proof order (SessionStart hook frame before system/init) and lets the @@ -42,7 +43,7 @@ function identity(providerSessionId: string): AgentSessionJournalIdentity { workspaceId: 'real-cli-fold-workspace', hostId: 'local', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: providerSessionId, leafUuid: null } + providerHandle: claudeProviderHandle(providerSessionId, null) } } diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index bd88413edba7..bd7d8b9eb63b 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -18,6 +18,7 @@ import { type ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const command = realClaudeCommand const suiteTitle = `Claude structured real CLI handshake${realClaudeCliGate.skipReason ? ` (skipped: ${realClaudeCliGate.skipReason})` : ''}` @@ -61,7 +62,7 @@ function identity(providerSessionId: string): AgentSessionJournalIdentity { workspaceId: 'real-cli-workspace', hostId: 'local', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: providerSessionId, leafUuid: null } + providerHandle: claudeProviderHandle(providerSessionId, null) } } @@ -106,13 +107,9 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { event.type === 'message' ? [event.message.subtype] : [] ) - expect(acquisition.link.handle).toMatchObject({ - provider: 'claude', - sessionId: providerSessionId, - // Init/SessionStart UUIDs are protocol frames, not resumable - // main-transcript leaves; no cursor exists before the first user turn. - leafUuid: null - }) + // Init/SessionStart UUIDs are protocol frames, not resumable + // main-transcript leaves; no cursor exists before the first user turn. + expect(acquisition.link.handle).toEqual(claudeProviderHandle(providerSessionId, null)) expect(observedSubtypes).toContain('hook_started') expect(adapter.readCommands('real-cli-handshake')).toContainEqual( expect.objectContaining({ diff --git a/src/main/claude/claude-structured-resume-point.test.ts b/src/main/claude/claude-structured-resume-point.test.ts index 862909a2cd66..0b68903c618d 100644 --- a/src/main/claude/claude-structured-resume-point.test.ts +++ b/src/main/claude/claude-structured-resume-point.test.ts @@ -88,7 +88,7 @@ describe('Claude resume point is the last completed turn on every exit path', () spawnToken: 'spawn-7', events: recordingJournalSink() }) - expect(acquisition.link.handle).toMatchObject({ leafUuid: 'a3' }) + expect(acquisition.link.handle).toMatchObject({ providerData: 'a3' }) await expect(adapter.closeSession('session-1')).resolves.toBe(true) expect(persisted).toEqual([expect.objectContaining({ leafUuid: 'a3' })]) }) diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index ee14b6b525a8..cd5b4841de6d 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -24,6 +24,7 @@ import { type FakeConnection } from './claude-structured-session-test-support' import { invokeCanUseTool } from './claude-can-use-tool-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' describe('ClaudeStructuredSessionAdapter.acquire', () => { it('pins the account and proves init without treating the system-frame uuid as a chain leaf', async () => { @@ -57,7 +58,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { }) expect(acquisition.link).toEqual({ linkId: `claude-7-${PROVIDER_SESSION_ID}-empty`, - handle: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null }, + handle: claudeProviderHandle(PROVIDER_SESSION_ID, null), origin: 'created', mintedAtFence: 7, observedAt: 1_700_000_000_500 @@ -424,11 +425,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { spawnToken: 'spawn-9' }) - expect(acquisition.link.handle).toEqual({ - provider: 'claude', - sessionId: PROVIDER_SESSION_ID, - leafUuid: null - }) + expect(acquisition.link.handle).toEqual(claudeProviderHandle(PROVIDER_SESSION_ID, null)) expect(events[0]).toMatchObject({ type: 'message', message: { subtype: 'hook_started', hook_name: 'SessionStart:startup' } @@ -476,11 +473,9 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { spawnToken: 'spawn-9' }) expect(acquisition.link.origin).toBe('resumed') - expect(acquisition.link.handle).toEqual({ - provider: 'claude', - sessionId: PROVIDER_SESSION_ID, - leafUuid: 'leaf-before' - }) + expect(acquisition.link.handle).toEqual( + claudeProviderHandle(PROVIDER_SESSION_ID, 'leaf-before') + ) const wrongClaude = fakeClaude({ initSessionId: 'different-session' }) await expect(endedAtStartup(wrongClaude)).resolves.toMatchObject({ diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index 71a7ac9ce319..2fd59d1a9e57 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -13,6 +13,10 @@ import { tick } from './claude-structured-session-test-support' import { invokeCanUseTool } from './claude-can-use-tool-test-support' +import { + claudeProviderHandle, + claudeProviderHandleLeafUuid +} from '../../shared/agent-session-provider-handle-encoding' describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { it('shares concurrent close finalization and emits lifecycle once', async () => { @@ -289,9 +293,9 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { let durableLeafUuid: string | null = null const resolveLaunch = vi.fn(async ({ identity }) => { if ( - identity.providerHandle.kind !== 'claude' || - identity.providerHandle.sessionId !== PROVIDER_SESSION_ID || - identity.providerHandle.leafUuid !== durableLeafUuid + !identity.providerHandle || + identity.providerHandle.nativeId !== PROVIDER_SESSION_ID || + claudeProviderHandleLeafUuid(identity.providerHandle) !== durableLeafUuid ) { throw new Error('claude durable resume identity changed before spawn') } @@ -378,11 +382,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { const replacement = await adapter.acquire({ identity: { ...identityFor(), - providerHandle: { - kind: 'claude', - sessionId: PROVIDER_SESSION_ID, - leafUuid: 'observed-retained-leaf' - } + providerHandle: claudeProviderHandle(PROVIDER_SESSION_ID, 'observed-retained-leaf') }, fence: 8, spawnToken: 'spawn-10', @@ -404,11 +404,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { expect(resolveLaunch).toHaveBeenNthCalledWith(2, { identity: { ...identityFor(), - providerHandle: { - kind: 'claude', - sessionId: PROVIDER_SESSION_ID, - leafUuid: 'observed-retained-leaf' - } + providerHandle: claudeProviderHandle(PROVIDER_SESSION_ID, 'observed-retained-leaf') } }) expect(oldPrompt.settled()).toBe(true) @@ -425,11 +421,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { } ]) expect(replacement.link).toMatchObject({ - handle: { - provider: 'claude', - sessionId: PROVIDER_SESSION_ID, - leafUuid: 'observed-retained-leaf' - }, + handle: claudeProviderHandle(PROVIDER_SESSION_ID, 'observed-retained-leaf'), origin: 'resumed', mintedAtFence: 8 }) diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index dd7ad1e8d8c5..02cf46aadc83 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -15,6 +15,7 @@ import { type ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' export const PROVIDER_SESSION_ID = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' @@ -30,7 +31,7 @@ export function identityFor(sessionId = 'session-1'): AgentSessionJournalIdentit workspaceId: 'workspace-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + providerHandle: claudeProviderHandle(PROVIDER_SESSION_ID, null) } } diff --git a/src/main/claude/claude-structured-turn-resume-point.test.ts b/src/main/claude/claude-structured-turn-resume-point.test.ts index 4cb72eaa53a6..fd6790b2deb8 100644 --- a/src/main/claude/claude-structured-turn-resume-point.test.ts +++ b/src/main/claude/claude-structured-turn-resume-point.test.ts @@ -6,7 +6,7 @@ import { import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' -import { reviseAgentSessionClaudeResumePoint } from '../runtime/agent-session-provider-handle-transition' +import { reviseAgentSessionProviderResumePoint } from '../runtime/agent-session-provider-handle-transition' import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' import { ClaudeStructuredSessionAdapter, @@ -20,6 +20,7 @@ import { recordingJournalSink, tick } from './claude-structured-session-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const FENCE = 7 @@ -36,14 +37,14 @@ function liveRecord(): AgentSessionRecord { origin: 'created', mintedAtFence: 1, observedAt: 500, - handle: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + handle: claudeProviderHandle(PROVIDER_SESSION_ID, null) }, { linkId: 'published-link', origin: 'resumed', mintedAtFence: FENCE, observedAt: 1_000, - handle: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: 'resumed-at' } + handle: claudeProviderHandle(PROVIDER_SESSION_ID, 'resumed-at') } ] } @@ -78,11 +79,10 @@ async function liveOwner( persistResumePoint: persistResumePoint ?? (async ({ providerSessionId, leafUuid, fence }) => { - store.record = reviseAgentSessionClaudeResumePoint({ + store.record = reviseAgentSessionProviderResumePoint({ record: store.record, fence, - providerSessionId, - leafUuid, + handle: claudeProviderHandle(providerSessionId, leafUuid), now: 2_000 }) }) @@ -115,12 +115,12 @@ describe('Claude durable resume point at turn end', () => { const { adapter, store, turn } = await liveOwner() try { await turn('u1', 'a1') - expect(store.record.providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'a1' }) + expect(store.record.providerHandleChain.at(-1)?.handle).toMatchObject({ providerData: 'a1' }) await turn('u2', 'a2') expect(store.record.providerHandleChain).toHaveLength(2) expect(store.record.providerHandleChain.at(-1)).toMatchObject({ linkId: 'published-link', - handle: { leafUuid: 'a2' } + handle: { providerData: 'a2' } }) } finally { await adapter.closeAll() @@ -135,7 +135,7 @@ describe('Claude durable resume point at turn end', () => { frame({ type: 'user', uuid: 'u3' }) await tick() const head = store.record.providerHandleChain.at(-1)!.handle - expect(head).toMatchObject({ leafUuid: 'a2' }) + expect(head).toMatchObject({ providerData: 'a2' }) const resolve = createClaudeStructuredLaunchResolver({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only getRecord from its store. @@ -147,7 +147,7 @@ describe('Claude durable resume point at turn end', () => { const launch = await resolve({ identity: { ...identityFor(store.record.sessionId), - providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: 'a2' } + providerHandle: claudeProviderHandle(PROVIDER_SESSION_ID, 'a2') } }) // Bookkeeping only: Claude continues from the end of its own conversation. diff --git a/src/main/claude/claude-subagent-resume-after-restart.test.ts b/src/main/claude/claude-subagent-resume-after-restart.test.ts index c4b848eee44c..d95dd3a48ffc 100644 --- a/src/main/claude/claude-subagent-resume-after-restart.test.ts +++ b/src/main/claude/claude-subagent-resume-after-restart.test.ts @@ -17,6 +17,7 @@ import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/ag import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { claudeSubagentGroupBody, claudeSubagentGroupIdentity } from './claude-subagent-group-row' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' // Frame orders are real sessions', scrubbed. A resumed agent's frames still name its ORIGINAL // spawn call while the announcement names the message call that resumed it, and a new provider @@ -129,7 +130,7 @@ async function openJournal(): Promise { workspaceId: 'workspace-1', hostId: 'local', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } + providerHandle: claudeProviderHandle('claude-session', null) }, now: () => 9_000, stateDirectory: root diff --git a/src/main/codex/codex-provider-retry-idle-sweep.test.ts b/src/main/codex/codex-provider-retry-idle-sweep.test.ts index 0e3e8385685e..722858828f25 100644 --- a/src/main/codex/codex-provider-retry-idle-sweep.test.ts +++ b/src/main/codex/codex-provider-retry-idle-sweep.test.ts @@ -19,6 +19,7 @@ import { import { createCodexJournalTranslator } from './codex-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SWEEP_MS = 5 const RETRY_GAP_MS = 10 * 60_000 @@ -61,7 +62,7 @@ beforeEach(async () => { acquisitionGeneration: 'generation-1', link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/codex/codex-structured-acquisition-exit-proof.test.ts b/src/main/codex/codex-structured-acquisition-exit-proof.test.ts index f0737fc36a36..f86d0aff6c1f 100644 --- a/src/main/codex/codex-structured-acquisition-exit-proof.test.ts +++ b/src/main/codex/codex-structured-acquisition-exit-proof.test.ts @@ -3,13 +3,14 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { CodexAppServerConnection } from './codex-app-server-connection-types' import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } describe('Codex failed-acquisition exit proof', () => { diff --git a/src/main/codex/codex-structured-dispatch-test-support.ts b/src/main/codex/codex-structured-dispatch-test-support.ts index 7513ec1fbbe4..9ac0f7f31bd9 100644 --- a/src/main/codex/codex-structured-dispatch-test-support.ts +++ b/src/main/codex/codex-structured-dispatch-test-support.ts @@ -14,6 +14,7 @@ import type { StructuredAgentSessionEventSink } from '../native-chat/agent-sessi import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' import { codexTurnLifecycleFake } from './codex-turn-lifecycle-fake' import type { CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' export const CODEX_TEST_THREAD_ID = 'thread-abc' @@ -114,7 +115,7 @@ export async function acquiredCodexAdapter(input: { workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: CODEX_TEST_THREAD_ID } + providerHandle: codexProviderHandle(CODEX_TEST_THREAD_ID) } await adapter.acquire({ identity, diff --git a/src/main/codex/codex-structured-journal-goal-revision.test.ts b/src/main/codex/codex-structured-journal-goal-revision.test.ts index aa455403ea78..11b559655b4d 100644 --- a/src/main/codex/codex-structured-journal-goal-revision.test.ts +++ b/src/main/codex/codex-structured-journal-goal-revision.test.ts @@ -15,6 +15,7 @@ import { readAgentSessionHistory } from '../native-chat/agent-session-wire/agent import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexJournalGoals } from './codex-structured-journal-goals' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const THREAD = '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc' const IDENTITY: AgentSessionJournalIdentity = { @@ -22,7 +23,7 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) } let root: string | null = null diff --git a/src/main/codex/codex-structured-journal-subagent-readers.test.ts b/src/main/codex/codex-structured-journal-subagent-readers.test.ts index a99b815bff48..87b888ec1a08 100644 --- a/src/main/codex/codex-structured-journal-subagent-readers.test.ts +++ b/src/main/codex/codex-structured-journal-subagent-readers.test.ts @@ -22,6 +22,7 @@ import { createCodexJournalTranslator } from './codex-structured-journal-transla import type { CodexThreadItem } from './codex-thread-item-identity' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION = 'session-codex-children' const PARENT = 'thread-parent' @@ -43,7 +44,7 @@ async function openJournal(root: string): Promise { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: PARENT } + providerHandle: codexProviderHandle(PARENT) }, database: openTestJournalHostDatabase(root), now: () => 1_000 diff --git a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts index 07115c4b4e77..0f51961b64d7 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts @@ -27,6 +27,7 @@ import { createCodexStructuredNotificationRetry } from './codex-structured-notif import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import type { CodexSession } from './codex-structured-session-state' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'session-1' const THREAD_ID = 'thread-abc' @@ -219,7 +220,7 @@ describe('codex turn lifecycle rows', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD_ID } + providerHandle: codexProviderHandle(THREAD_ID) }, now: () => 9_000, stateDirectory: join(root, SESSION_ID) diff --git a/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts b/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts index da24f836d1a7..05748ead80f7 100644 --- a/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts +++ b/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts @@ -14,6 +14,7 @@ import { createTrackedJournalOpener } from '../native-chat/agent-session-journal import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION = 'session-codex-failed-turn' const THREAD = 'thread-abc' @@ -35,7 +36,7 @@ async function session() { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, stateDirectory: root, now: () => 1_000 diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index 36ab39e3babc..1e6a6b4805e5 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -5,6 +5,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { createCodexStructuredLaunchResolver } from './codex-structured-launch-resolution' import { codexStructuredPermissionPolicyForSettings } from './codex-structured-permission-policy' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'session-1' const IDENTITY = { sessionId: SESSION_ID } as Parameters< @@ -105,9 +106,10 @@ describe('codex structured launch resolution', () => { it('resumes the last thread this session actually proved, not one a caller names', async () => { const launch = await resolverFor( record({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset. providerHandleChain: [ - { handle: { provider: 'codex', threadId: 'thread-old' } }, - { handle: { provider: 'codex', threadId: 'thread-current' } } + { handle: codexProviderHandle('thread-old') }, + { handle: codexProviderHandle('thread-current') } ] as AgentSessionRecord['providerHandleChain'] }) )({ identity: IDENTITY }) @@ -121,7 +123,7 @@ describe('codex structured launch resolution', () => { mintedAtFence: number ): AgentSessionProviderHandleLink => ({ linkId: `link-${mintedAtFence}`, - handle: { provider: 'codex', threadId: 't' }, + handle: codexProviderHandle('t'), origin, mintedAtFence, observedAt: 1 @@ -204,8 +206,9 @@ describe('codex structured launch resolution', () => { const resolveRollout = vi.fn(async () => '/home/work/.codex/sessions/rollout.jsonl') const launch = await resolverFor( record({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset. providerHandleChain: [ - { handle: { provider: 'codex', threadId: 'thread-current' } } + { handle: codexProviderHandle('thread-current') } ] as AgentSessionRecord['providerHandleChain'] }), async (id) => `/repos/${id}`, diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index ebfdb27200a5..19945c9a6c89 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -93,7 +93,8 @@ export function createCodexStructuredLaunchResolver( // concern, and the permission posture they used to smuggle in is derived per acquisition. const permissionPolicy = deps.resolvePermissionPolicy?.() const head = agentSessionProviderHandleChainHead(record.providerHandleChain) - const resumeThreadId = head?.handle.provider === 'codex' ? head.handle.threadId : null + // A Codex record's chain holds only Codex handles; the record store refuses anything else. + const resumeThreadId = head?.handle.nativeId ?? null // The same saved options every turn sends, so the thread and its turns name one model. const model = record.options?.model return { diff --git a/src/main/codex/codex-structured-owner-identity.test.ts b/src/main/codex/codex-structured-owner-identity.test.ts index c86885171e47..229da5e00bd1 100644 --- a/src/main/codex/codex-structured-owner-identity.test.ts +++ b/src/main/codex/codex-structured-owner-identity.test.ts @@ -1,12 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const IDENTITY = { sessionId: 'session-identity', workspaceId: 'workspace-1', hostId: 'local', agent: 'codex' as const, - providerHandle: { kind: 'codex' as const, threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } describe('codex process identity', () => { diff --git a/src/main/codex/codex-structured-owner-identity.ts b/src/main/codex/codex-structured-owner-identity.ts index 805a767e6200..9b0d3e8e5488 100644 --- a/src/main/codex/codex-structured-owner-identity.ts +++ b/src/main/codex/codex-structured-owner-identity.ts @@ -3,6 +3,7 @@ import { agentSessionProviderHandleKey, type AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' import { readProcessStartTimeMs } from '../runtime/agent-session-process-identity-probe' @@ -91,16 +92,15 @@ export function codexProviderHandleLink( ): AgentSessionProviderHandleLink { return { linkId: input.linkId ?? `codex-${input.fence}-${input.threadId}`.slice(0, 128), - handle: { provider: 'codex', threadId: input.threadId }, + handle: codexProviderHandle(input.threadId), origin: input.origin ?? (input.resumed ? 'resumed' : 'created'), mintedAtFence: input.fence, observedAt: input.observedAt, ...(input.supersedesThreadId ? { - supersedesKey: agentSessionProviderHandleKey({ - provider: 'codex', - threadId: input.supersedesThreadId - }) + supersedesKey: agentSessionProviderHandleKey( + codexProviderHandle(input.supersedesThreadId) + ) } : {}) } diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index 8ad4b75bd38e..388a174fad7c 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -37,6 +37,7 @@ import { } from '../../renderer/src/components/native-chat/structured-agent-question-projection' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -79,7 +80,7 @@ beforeEach(async () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: HOST_TEST_NOW diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 37c34a4bf3a1..51dee838147b 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -1,3 +1,7 @@ +import { + CODEX_STRUCTURED_HANDLE_NAMESPACE, + isAgentSessionProviderHandleInNamespace +} from '../../shared/agent-session-provider-handle-encoding' import { AgentSessionAcquisitionRefusal, AgentSessionPreSpawnError, @@ -71,9 +75,11 @@ export async function acquireCodexStructuredSession(input: { const { previousAttempt, attempt } = acquisitions.start(sessionId) const acquisition = attempt.window let unbindReadingControl: (() => void) | undefined + const provenHandle = acquireInput.identity.providerHandle let primaryThreadId = - acquireInput.identity.providerHandle.kind === 'codex' - ? acquireInput.identity.providerHandle.threadId + provenHandle && + isAgentSessionProviderHandleInNamespace(provenHandle, CODEX_STRUCTURED_HANDLE_NAMESPACE) + ? provenHandle.nativeId : null const subagentExecutions = new CodexSubagentExecutions() const dispatchEchoes = createCodexDispatchEchoes() diff --git a/src/main/codex/codex-structured-session-adapter-fixture.ts b/src/main/codex/codex-structured-session-adapter-fixture.ts index f2b4e102c355..ee174e822a21 100644 --- a/src/main/codex/codex-structured-session-adapter-fixture.ts +++ b/src/main/codex/codex-structured-session-adapter-fixture.ts @@ -14,6 +14,7 @@ import { type CodexStructuredSessionEvent } from './codex-structured-session-adapter' import type { CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' export const THREAD_ID = 'thread-abc' @@ -23,7 +24,7 @@ export function identityFor(sessionId: string): AgentSessionJournalIdentity { workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD_ID } + providerHandle: codexProviderHandle(THREAD_ID) } } diff --git a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts index e3211f320e85..8bc3c8611c61 100644 --- a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts +++ b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts @@ -16,6 +16,7 @@ import { type CodexStructuredLaunch, type CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const THREAD_ID = 'thread-abc' @@ -25,7 +26,7 @@ function identityFor(sessionId: string): AgentSessionJournalIdentity { workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD_ID } + providerHandle: codexProviderHandle(THREAD_ID) } } diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 70b2ecd7147a..621c453b0572 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -20,6 +20,7 @@ import { fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' describe('CodexStructuredSessionAdapter.acquire', () => { it('starts a new thread and reports the process and link the lease will prove', async () => { @@ -55,7 +56,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { }) expect(acquisition.link).toEqual({ linkId: `codex-7-${THREAD_ID}`, - handle: { provider: 'codex', threadId: THREAD_ID }, + handle: codexProviderHandle(THREAD_ID), origin: 'created', mintedAtFence: 7, observedAt: 1_700_000_000_500 @@ -99,7 +100,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { }) }) expect(acquisition.link.origin).toBe('resumed') - expect(acquisition.link.handle).toEqual({ provider: 'codex', threadId: 'thread-proven' }) + expect(acquisition.link.handle).toEqual(codexProviderHandle('thread-proven')) }) it('starts a thread in place of a creation Codex never saved, and says which it replaced', async () => { @@ -129,7 +130,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { ]) expect(acquisition.link).toEqual({ linkId: `codex-9-${THREAD_ID}`, - handle: { provider: 'codex', threadId: THREAD_ID }, + handle: codexProviderHandle(THREAD_ID), origin: 'created', supersedesKey: 'codex:"thread-unsaved"', mintedAtFence: 9, diff --git a/src/main/codex/codex-structured-session-background-tasks.test.ts b/src/main/codex/codex-structured-session-background-tasks.test.ts index 4e2515da7d43..466cadce8a13 100644 --- a/src/main/codex/codex-structured-session-background-tasks.test.ts +++ b/src/main/codex/codex-structured-session-background-tasks.test.ts @@ -10,6 +10,7 @@ import { CodexStructuredSessionAdapter } from './codex-structured-session-adapte import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexStructuredSessionEvent } from './codex-structured-session-state' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' // Proves the host's child records are actually REACHED from provider traffic: the tracker is // unit-tested separately, and a producer that is correct but unwired publishes nothing while @@ -51,7 +52,7 @@ function identity(sessionId: string): AgentSessionJournalIdentity { workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD_ID } + providerHandle: codexProviderHandle(THREAD_ID) } } diff --git a/src/main/codex/codex-structured-session-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts index cd30bfa2e6d1..49112d6b862e 100644 --- a/src/main/codex/codex-structured-session-cancel.test.ts +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -47,6 +47,7 @@ import { type CodexStructuredSessionAdapterDeps, type CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const THREAD_ID = 'thread-abc' const USER_MESSAGE: AgentJournalMessageItem = { @@ -69,7 +70,7 @@ function identity(): AgentSessionJournalIdentity { workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD_ID } + providerHandle: codexProviderHandle(THREAD_ID) } } diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index 9d80a0fa9829..cb443a157e02 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -17,6 +17,7 @@ import { CodexPromptRegistry } from './codex-structured-prompt-replies' import type { CodexSession } from './codex-structured-session-state' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const THREAD = 'thread-1' @@ -26,7 +27,7 @@ function identity(sessionId: string): AgentSessionJournalIdentity { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) } } diff --git a/src/main/codex/codex-structured-session-shutdown.test.ts b/src/main/codex/codex-structured-session-shutdown.test.ts index 3883dad4600e..44ed5c93d411 100644 --- a/src/main/codex/codex-structured-session-shutdown.test.ts +++ b/src/main/codex/codex-structured-session-shutdown.test.ts @@ -8,6 +8,7 @@ import { CodexStructuredSessionAdapter, type CodexStructuredLaunch } from './codex-structured-session-adapter' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'session-1' const THREAD_ID = 'thread-1' @@ -25,7 +26,7 @@ function identity(): AgentSessionJournalIdentity { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD_ID } + providerHandle: codexProviderHandle(THREAD_ID) } } diff --git a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts b/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts index 8c4db243aa79..899a3ff7bcc7 100644 --- a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts @@ -28,13 +28,14 @@ import { updateTestJournalRowJson, deleteTestJournalRow } from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } let root: string diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts index a6821cf8427b..278fea939737 100644 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -26,13 +26,14 @@ import { type ProviderHistoryWindow } from './journal-submission-reconciler' import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const TURN_ID = '019fd8ca-edbe-7c43-b231-4c7aea3a2d89' diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts index cefd4bbe368e..574dec492ee9 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts @@ -11,13 +11,14 @@ import { import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import type { openAgentSessionJournal } from './journal-store-factory' import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const HUGE = 'x'.repeat(4 * DEFAULT_JOURNAL_PAYLOAD_LIMITS.inlineHeadBytes) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts index 2992459a787a..6337b00417bf 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts @@ -19,19 +19,20 @@ import { openTestJournalHostDatabase, readTestJournalRows } from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const PEER: AgentSessionJournalIdentity = { ...IDENTITY, sessionId: 'session-peer', - providerHandle: { kind: 'codex', threadId: 'thread-peer' } + providerHandle: codexProviderHandle('thread-peer') } let root: string diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index 70efc8912837..b2754e33acaf 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -11,6 +11,7 @@ import type { AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { agentSessionJournalProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import type { JournalHostDatabase } from './journal-host-database' import type { JournalLoad } from './journal-open' import { clearJournalRepairMarker } from './journal-repair-marker' @@ -58,7 +59,10 @@ export function replaceJournalEpoch(input: { const epochRow: JournalRow = { kind: 'epoch', reason: input.reason, - providerHandle: input.identity.providerHandle, + providerHandle: agentSessionJournalProviderHandle( + input.identity.providerHandle, + input.identity.agent + ), ...journalRowBase(epoch, 1, input.fence, input.now()) } const rows: JournalRow[] = [epochRow] diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts index 855f60ac3261..26296f89d109 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts @@ -7,6 +7,7 @@ import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { agentSessionJournalProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import type { JournalHostDatabase } from './journal-host-database' import type { JournalLoad } from './journal-open' import { clearJournalRepairMarker } from './journal-repair-marker' @@ -32,7 +33,10 @@ export function publishNewEpoch(input: { const row: JournalRow = { kind: 'epoch', reason: input.reason, - providerHandle: input.identity.providerHandle, + providerHandle: agentSessionJournalProviderHandle( + input.identity.providerHandle, + input.identity.agent + ), // Carries no body: an older host must keep reading a turn-free session past row 1. v: journalRowSchemaVersion([]), epoch: input.epoch, diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts index ed27ed6fa0b2..bd3dff1fb656 100644 --- a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts @@ -23,13 +23,14 @@ import { deleteTestJournalRow, insertTestJournalRowJson } from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const DISCLOSURE_ITEM_ID = agentJournalItemKey(JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY) diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts index 84ff9775022b..ba2819094ad4 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts @@ -20,19 +20,20 @@ import { readTestJournalRows } from './journal-host-database-test-support' import type Database from '../../sqlite/sync-database' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const OTHER: AgentSessionJournalIdentity = { ...IDENTITY, sessionId: 'session-2', - providerHandle: { kind: 'codex', threadId: 'thread-2' } + providerHandle: codexProviderHandle('thread-2') } const SUBMISSION = { diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts index a38a37fd0c82..9599fa33c5be 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -8,10 +8,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { - AgentSessionJournalIdentity, - AgentSessionProviderHandle -} from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentSessionProviderHandle } from '../../../shared/agent-session-provider-handle' import type { NativeChatMessage } from '../../../shared/native-chat-types' import type * as TranscriptLineDecoders from '../transcript-line-decoders' import { createLegacyIdentityTracker } from './journal-legacy-identity' @@ -20,6 +18,10 @@ import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import { openAgentSessionJournal } from './journal-store-factory' import type { AgentSessionJournal } from './journal-store' import { openTestJournalHostDatabase } from './journal-host-database-test-support' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' // No shipped decoder emits a subagent roster, so the roster bounds are reached by standing one in. const decodedClaudeOverride = vi.hoisted((): { message: NativeChatMessage | null } => ({ @@ -50,11 +52,11 @@ type ImportAgent = 'claude' | 'codex' | 'grok' | 'omp' function providerHandle(agent: ImportAgent, sessionId: string): AgentSessionProviderHandle { if (agent === 'claude') { - return { kind: 'claude', sessionId, leafUuid: null } + return claudeProviderHandle(sessionId, null) } return agent === 'codex' - ? { kind: 'codex', threadId: sessionId } - : { kind: 'opaque', agent, value: sessionId } + ? codexProviderHandle(sessionId) + : { transport: 'terminal-transcript', agent, nativeId: sessionId } } function identity(agent: ImportAgent, sessionId: string): AgentSessionJournalIdentity { diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts index ebe2da08a55b..e80ac7acf90c 100644 --- a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts @@ -29,6 +29,7 @@ import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' import { importPerSessionJournal } from './journal-per-session-import' import { readJournalSessionEpoch, type JournalStoredRow } from './journal-row-table' import { createStructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() @@ -40,7 +41,7 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'ws-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } let root: string diff --git a/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts b/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts index d41e9b363ed2..8dea06cebee6 100644 --- a/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts @@ -16,6 +16,7 @@ import { journalLifecycleBatchRowBuilder } from './journal-row-builders' import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' // A row's producer is fixed by the write that created it. A revision naming no // producer — a settlement, a prompt answer, a reopen sweep — keeps it; a @@ -170,7 +171,7 @@ describe('producer inheritance across a reopen', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'root' } + providerHandle: codexProviderHandle('root') }, stateDirectory: root, now: () => 1_000 diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts index 2f28adf0d545..8e541f110b00 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts @@ -19,13 +19,14 @@ import { createTrackedJournalOpener } from './journal-host-database-test-support import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { classifyDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: null } + providerHandle: claudeProviderHandle('provider-1', null) } function claudeIdentity(uuid: string): AgentJournalItemIdentity { diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 0865df3a41fa..2cf3f60b0e6f 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -5,8 +5,10 @@ import type { AgentJournalProducerLinkage, AgentJournalRowAttribution, AgentJournalTurnScope, - AgentSessionProviderHandle + AgentSessionJournalIdentity, + AgentSessionJournalProviderHandle } from '../../../shared/agent-session-journal-types' +import { agentSessionJournalProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import { agentJournalLinkageFields, @@ -63,7 +65,7 @@ export function journalTombstoneRowBuilder( export function journalSubmissionRowBuilder( state: () => JournalReducerState, - providerHandle: AgentSessionProviderHandle, + identity: Pick, input: { clientMessageId: string payloadFingerprint: string @@ -85,7 +87,7 @@ export function journalSubmissionRowBuilder( const queuedMessageId = consume?.messageId ?? input.queuedMessageId return buildJournalSubmissionRow({ state: state(), - providerHandle, + providerHandle: agentSessionJournalProviderHandle(identity.providerHandle, identity.agent), ...input, ...(queuedMessageId !== undefined ? { queuedMessageId } : {}), seq, @@ -300,7 +302,7 @@ export function buildJournalSubmissionRow(input: { state: JournalReducerState clientMessageId: string payloadFingerprint: string - providerHandle: AgentSessionProviderHandle + providerHandle: AgentSessionJournalProviderHandle body: AgentJournalMessageItem seq: number fence: number diff --git a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts index 623c97445a61..83bb81851fca 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts @@ -19,13 +19,14 @@ import { liveTestJournalRows, updateTestJournalRowJson } from './journal-host-database-test-support' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } + providerHandle: claudeProviderHandle('claude-session', null) } const USAGE = { diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts b/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts index 616f4b557562..4fdaaa880b12 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts @@ -9,6 +9,7 @@ import { openTestJournalHostDatabase, liveTestJournalRows } from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' // Which rows an older host can still read: only rows that carry a turn item // are stamped with the version it does not know, and the epoch row never is. @@ -33,7 +34,7 @@ describe('journal row schema versions', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, now: () => 1_000, stateDirectory: join(root, 'session-1') diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 9276b1a8c05f..8df9492beb7f 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -21,7 +21,7 @@ import { type AgentJournalMessageItem, type AgentJournalProducerLinkage, type AgentJournalTurnScope, - type AgentSessionProviderHandle + type AgentSessionJournalProviderHandle } from '../../../shared/agent-session-journal-types' import { isAdmissibleAgentJournalItemBody, @@ -57,7 +57,7 @@ type JournalRowBase = AgentJournalProducerLinkage & { export type JournalEpochRow = JournalRowBase & { kind: 'epoch' reason: AgentJournalEpochReason - providerHandle: AgentSessionProviderHandle + providerHandle: AgentSessionJournalProviderHandle } export const AGENT_JOURNAL_EPOCH_REASONS = [ @@ -121,7 +121,7 @@ export type JournalSubmissionRow = JournalRowBase & { kind: 'submission' clientMessageId: string payloadFingerprint: string - providerHandle: AgentSessionProviderHandle + providerHandle: AgentSessionJournalProviderHandle body: AgentJournalMessageItem /** Accepted to be handed over by a later `dispatch{pending}` row; absent on rows whose writer * dispatched in the same step. Older readers keep the key and ignore it. */ diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts index 8eee42a70d03..a7fedb6a0a10 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts @@ -23,13 +23,14 @@ import { buildJournalQueueResumeRow, buildJournalStopEventRow } from './journal-stop-and-resume-rows' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-s', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const EPOCH = 'epoch-1' diff --git a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts index d7c5e521c821..d9b59539bd03 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts @@ -27,13 +27,14 @@ import { liveTestJournalRows, insertTestJournalRowJson } from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } let root: string diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts index 871aca7880dc..f885ee94543c 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -29,13 +29,14 @@ import { deleteTestJournalRow } from './journal-host-database-test-support' import type Database from '../../sqlite/sync-database' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } let root: string diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 5a4880155dec..5be970df78d0 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -343,7 +343,7 @@ export class AgentSessionJournal { consume?: JournalSubmissionConsume ): Promise { return this.rowWriter.append( - journalSubmissionRowBuilder(() => this.state, this.identity.providerHandle, input, consume), + journalSubmissionRowBuilder(() => this.state, this.identity, input, consume), consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume) ) } diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts index 6b519ce35cd8..4da76e0ba429 100644 --- a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts @@ -21,13 +21,14 @@ import { import type { openAgentSessionJournal } from './journal-store-factory' import { createTrackedJournalOpener } from './journal-host-database-test-support' import { staleSubagentRosterRevisions } from './journal-subagent-liveness' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const GROUP_ID = 'thread-1:turn-1' diff --git a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts index fc9431646158..1159a37ef33f 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts @@ -15,13 +15,14 @@ import { createJournalReducerState, applyJournalRow } from './journal-reducer' import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-q', workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } + providerHandle: claudeProviderHandle('native-1', null) } const BODY: AgentJournalMessageItem = { kind: 'message', diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts index 794ca52ab7d5..55a8a0460858 100644 --- a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts @@ -20,13 +20,14 @@ import { openTestJournalHostDatabase } from './journal-host-database-test-support' import { parseJournalRow, type JournalRow } from './journal-row-schema' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-newer-kind', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const SCOPE = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts index 3cdd554cc4e8..9b5653b060f8 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts @@ -20,13 +20,14 @@ import { openJournalOwingImport } from './journal-owed-import-test-support' import type { AgentSessionJournal } from './journal-store' import { openAgentSessionJournal } from './journal-store-factory' import { JournalWriteQueue } from './journal-write-queue' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY = { sessionId: 'session-1', workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } as const const reply = (text: string): AgentJournalMessageItem => ({ diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts index 638a62bdf02a..3dfa581c70d4 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts @@ -20,13 +20,14 @@ import { closeTestJournalHostDatabases, createTrackedJournalOpener } from './journal-host-database-test-support' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-q', workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } + providerHandle: claudeProviderHandle('native-1', null) } const REFUSAL = agentSessionFailureWords( agentSessionFailureFact('providerRejected', { diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts index 4ce62c6192b2..a12630d6566d 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts @@ -17,13 +17,14 @@ import { queuedMessageFingerprint } from '../agent-session-wire/structured-agent import { JournalQueuedMessages } from './journal-queued-messages' import type { AgentSessionJournal } from './journal-store' import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-q', workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } + providerHandle: claudeProviderHandle('native-1', null) } const STOP_WITHDRAWAL = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index b8e45e0ef590..a2fa19a6a5c3 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -29,13 +29,14 @@ import { } from './queued-message-pause' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-p', workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } + providerHandle: claudeProviderHandle('native-1', null) } const HOST = 'proc-1' diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index c3910e466854..8a627834e285 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -26,13 +26,14 @@ import { closeTestJournalHostDatabases, createTrackedJournalOpener } from './journal-host-database-test-support' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-q', workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } + providerHandle: claudeProviderHandle('native-1', null) } let root: string diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts index 7711b670438a..115e2248ea0f 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts @@ -11,13 +11,14 @@ import type { import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { readAgentSessionHistory } from './agent-session-history-page' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const journals = createTrackedJournalOpener() diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts index 8438b40d1224..9cbfb94a9079 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts @@ -19,6 +19,7 @@ import { readAgentSessionHistory, readAgentSessionHydrationPage } from './agent-session-history-page' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' // A backward page windows over the session's own rows, and a subagent's rows in // that range ride along, so a burst cannot crowd the conversation off the page. @@ -28,7 +29,7 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const child: AgentJournalProducerLinkage = { agentId: 'task-1', producerKind: 'agent' } diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts index 8abfce258b85..1a1e7fda2cd8 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts @@ -20,13 +20,14 @@ import { } from '../agent-session-journal/journal-host-database-test-support' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' import { readAgentSessionHistory } from './agent-session-history-page' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const identity: AgentSessionJournalIdentity = { sessionId: 'bounded-catch-up', workspaceId: 'folder-workspace', hostId: 'remote-host', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const journals = createTrackedJournalOpener() let root: string | undefined diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts index e40ebcf326ef..de31297ee254 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts @@ -35,13 +35,14 @@ import type { import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { projectJournalBatch } from './agent-session-journal-batch' import { readAgentSessionHistory, resolveHistoryLimit } from './agent-session-history-page' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const journals = createTrackedJournalOpener() diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts index 52fc3c86b9b6..4e4de1300eae 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts @@ -33,6 +33,10 @@ import { } from './agent-session-journal-recovery' import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../agent-session-journal/journal-open-failure' import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' // The store's replay goes through the mock: it is the only read of the journal an open makes. vi.mock('../agent-session-journal/journal-open', async (importOriginal) => { @@ -47,7 +51,7 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: CODEX_SESSION } + providerHandle: codexProviderHandle(CODEX_SESSION) } const CODEX_LINES = [ @@ -156,10 +160,8 @@ afterEach(async () => { describe('providerHistoryId', () => { it('uses the provider handle, never the Orca session id', () => { - expect(providerHistoryId({ kind: 'codex', threadId: 'thread-9' })).toBe('thread-9') - expect(providerHistoryId({ kind: 'claude', sessionId: 'sess-9', leafUuid: null })).toBe( - 'sess-9' - ) + expect(providerHistoryId(codexProviderHandle('thread-9'))).toBe('thread-9') + expect(providerHistoryId(claudeProviderHandle('sess-9', null))).toBe('sess-9') }) }) @@ -311,7 +313,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const neighbour: AgentSessionJournalIdentity = { ...IDENTITY, sessionId: 'neighbour-session', - providerHandle: { kind: 'codex', threadId: 'neighbour-thread' } + providerHandle: codexProviderHandle('neighbour-thread') } const other = await journals.open({ identity: neighbour, stateDirectory: journalDir }) for (let ordinal = 1; ordinal <= 3; ordinal += 1) { diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts index 42da9a986dad..4f75fdec3755 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts @@ -8,9 +8,10 @@ import type { AgentType } from '../../../shared/agent-status-types' import type { AgentJournalResetReason, - AgentSessionJournalIdentity, - AgentSessionProviderHandle + AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentSessionProviderHandle } from '../../../shared/agent-session-provider-handle' +import { AGENT_SESSION_PENDING_PROVIDER_HANDLE_VALUE } from '../../../shared/agent-session-provider-handle-encoding' import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -34,11 +35,8 @@ export type AgentSessionJournalOpened = { /** The provider's own session id, which is what the transcript readers index * by — never the Orca session id. */ -export function providerHistoryId(handle: AgentSessionProviderHandle): string { - if (handle.kind === 'codex') { - return handle.threadId - } - return handle.kind === 'claude' ? handle.sessionId : handle.value +export function providerHistoryId(handle: AgentSessionProviderHandle | null): string { + return handle?.nativeId ?? AGENT_SESSION_PENDING_PROVIDER_HANDLE_VALUE } export async function openAgentSessionJournalWithRecovery(input: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index b3f91aea7002..7cc6e05a6a9b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -46,6 +46,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -66,7 +67,7 @@ const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spa acquisitionGeneration: `generation-${acquire.mock.calls.length}`, link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? ('resumed' as const) : ('created' as const), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index c98854292d5c..197585c42b59 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -21,6 +21,7 @@ import { performAttach } from './structured-agent-session-attach-flow' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const SESSION = 'legacy-session' @@ -90,7 +91,7 @@ function adapter(input: { }, link: { linkId: `${input.origin}-link`, - handle: { provider: 'codex', threadId: 'legacy-thread' }, + handle: codexProviderHandle('legacy-thread'), origin: input.origin, mintedAtFence: fence, observedAt: NOW @@ -139,7 +140,7 @@ describe('structured session acquisition options', () => { }, link: { linkId: `${origin}-link`, - handle: { provider: 'codex', threadId: 'legacy-thread' }, + handle: codexProviderHandle('legacy-thread'), origin, mintedAtFence: input.fence, observedAt: NOW @@ -460,7 +461,7 @@ describe('structured session acquisition options', () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: 'legacy-thread' }, + handle: codexProviderHandle('legacy-thread'), origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts index e22dd1bd2890..076e2de7540d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -8,6 +8,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' function claudeIdentity(sessionId: string): AgentSessionJournalIdentity { return { @@ -15,7 +16,7 @@ function claudeIdentity(sessionId: string): AgentSessionJournalIdentity { workspaceId: 'workspace-1', hostId: 'local', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'provider-session-1', leafUuid: null } + providerHandle: claudeProviderHandle('provider-session-1', null) } } @@ -24,7 +25,7 @@ function acquisition(fence: number, spawnToken: string): AgentSessionAcquisition process: { hostId: 'local', pid: 1, processStartTimeMs: 1, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: null }, + handle: claudeProviderHandle('provider-session-1', null), origin: 'created', mintedAtFence: fence, observedAt: 1 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 76a276abd230..4f532865452a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -20,6 +20,7 @@ import * as legacyImport from '../agent-session-journal/journal-legacy-import' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const SESSION = 'codex_adopting_session' @@ -101,7 +102,7 @@ function adapter(): StructuredAgentSessionAdapter { process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, link: { linkId: 'resumed-link', - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'resumed', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts index 3eccc4f94571..e86e30d8f5dc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts @@ -5,6 +5,7 @@ import { type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { agentSessionProviderHandleFromWire } from '../../../shared/agent-session-provider-handle-encoding' import type { AgentSessionAttachParams, AttachedJournal } from './structured-agent-session-attach' import type { JournalReplacementItem } from '../agent-session-journal/journal-epoch-replacement' import { @@ -49,10 +50,7 @@ async function readAdoptedTranscript( } const prepared = await prepareLegacyTranscriptImport({ agent: params.agent, - sessionId: - adopt.providerHandle.kind === 'claude' - ? adopt.providerHandle.sessionId - : adopt.providerHandle.threadId, + sessionId: agentSessionProviderHandleFromWire(adopt.providerHandle).nativeId, options: { filePath: adopt.transcriptPath } }) if (!prepared.ok) { @@ -100,10 +98,7 @@ async function applyAdoptedTranscript( const imported = await importLegacyTranscriptIntoJournal({ journal: attached.journal, agent: params.agent, - sessionId: - adopt.providerHandle.kind === 'claude' - ? adopt.providerHandle.sessionId - : adopt.providerHandle.threadId, + sessionId: agentSessionProviderHandleFromWire(adopt.providerHandle).nativeId, fence: record.lease.runtimeFence, options: { filePath: adopt.transcriptPath } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index 6111e9029621..5c718da0f3be 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -27,6 +27,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -119,7 +120,7 @@ beforeEach(async () => { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: generation === 0 ? ('created' as const) : ('resumed' as const), mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts index 140a99201156..4b52c9446dec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts @@ -28,6 +28,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const caller = { callerKey: 'desktop' } const CLAUDE_SESSION = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' @@ -56,7 +57,7 @@ function adapter(catalog: ClaudeAtRestCommandCatalog): StructuredAgentSessionAda mintedAtFence: input.fence, observedAt: HOST_TEST_NOW, origin: 'created' as const, - handle: { provider: 'claude' as const, sessionId: CLAUDE_SESSION, leafUuid: null } + handle: claudeProviderHandle(CLAUDE_SESSION, null) } })), atRestCommands: catalog, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 85f99056c3a7..415e261ab3ca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -5,15 +5,17 @@ // the record store's compare-and-swap, which also owns the idempotency row, so // a retried attach replays instead of reserving a second owner. -import type { - AgentSessionJournalIdentity, - AgentSessionProviderHandle -} from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionHandleProvider, AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' +import { + agentSessionProviderHandleBelongsTo, + agentSessionProviderHandleFromWire, + type AgentSessionWireProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' import { claudeProviderHandleLink } from '../../claude/claude-structured-owner-identity' import { codexProviderHandleLink } from '../../codex/codex-structured-owner-identity' import type { @@ -65,8 +67,9 @@ export type AgentSessionAttachParams = { * attach fingerprint: which tab shows the chat is not which conversation it attaches to. */ surfaceTabId?: string launchArgs?: string[] - /** Omitted only for create-by-intent; the adapter proves the durable handle. */ - providerHandle?: Exclude + /** Omitted only for create-by-intent; the adapter proves the durable handle. In the wire's + * form, because the attach fingerprint covers it as the client sent it. */ + providerHandle?: AgentSessionWireProviderHandle /** * Host-resolved only. Present when this create adopts an existing provider conversation rather * than starting one: it seeds the handle chain so the adapter resumes instead of creating, and @@ -76,7 +79,7 @@ export type AgentSessionAttachParams = { * without adopting — presence of a handle must never be what triggers a resume. */ adopt?: { - providerHandle: Exclude + providerHandle: AgentSessionWireProviderHandle /** Omitted only when the exact committed operation replays an already-imported journal. */ transcriptPath?: string } @@ -119,7 +122,13 @@ export function attachFingerprintFields(params: AgentSessionAttachParams): Recor export function admitAttachOrRefuse( params: AgentSessionAttachParams ): { ok: true; fingerprint: string } | { ok: false; refusal: AgentSessionWireRefusal } { - if (params.providerHandle && params.providerHandle.kind !== params.provider) { + if ( + params.providerHandle && + !agentSessionProviderHandleBelongsTo( + agentSessionProviderHandleFromWire(params.providerHandle), + params.provider + ) + ) { return { ok: false, refusal: refuse( @@ -143,16 +152,9 @@ export function journalIdentityFor( params: AgentSessionAttachParams ): AgentSessionJournalIdentity { const head = agentSessionProviderHandleChainHead(record.providerHandleChain) - const providerHandle: AgentSessionProviderHandle = - head?.handle.provider === 'codex' - ? { kind: 'codex', threadId: head.handle.threadId } - : head?.handle.provider === 'claude' - ? { - kind: 'claude', - sessionId: head.handle.sessionId, - leafUuid: head.handle.leafUuid - } - : (params.providerHandle ?? { kind: 'opaque', agent: params.agent, value: 'pending' }) + const providerHandle = + head?.handle ?? + (params.providerHandle ? agentSessionProviderHandleFromWire(params.providerHandle) : null) return { sessionId: record.sessionId, workspaceId: params.location.workspaceId, @@ -265,7 +267,7 @@ async function reconcileAgainstProviderHistory(input: { const ADOPTED_HANDLE_FENCE = 1 function adoptedProviderHandleLink( - handle: Exclude, + handle: AgentSessionWireProviderHandle, observedAt: number ): AgentSessionProviderHandleLink { return handle.kind === 'claude' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts index 1bb3d36a5c43..868247eb37ce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts @@ -17,6 +17,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-claude' } const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' @@ -55,7 +56,7 @@ function adapter(): StructuredAgentSessionAdapter { process: { hostId: 'local', pid: 4200, processStartTimeMs: NOW, spawnToken }, link: { linkId: `claude-native-${fence}`, - handle: { provider: 'claude', sessionId: CLAUDE_SESSION, leafUuid: 'native-leaf' }, + handle: claudeProviderHandle(CLAUDE_SESSION, 'native-leaf'), origin: acquire.mock.calls.length === 1 ? 'created' : 'resumed', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index ddef0ed0bb65..24657cad342d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -512,7 +512,7 @@ it('starts a new child for the next send after a Stop, on the same Claude conver }) // The wake resumes the same Claude conversation; the first test pins the leaf it resumes after. expect(store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toMatchObject({ - sessionId: PROVIDER_SESSION_ID + nativeId: PROVIDER_SESSION_ID }) expect(resumed.closed).toBe(false) expect(await dispatch(next)).toMatchObject({ state: 'pending' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index aab219c779d6..7421fcef307c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -27,6 +27,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -77,7 +78,7 @@ beforeEach(async () => { acquisitionGeneration: 'generation-1', link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created' as const, mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts index ec4c76e26ba2..94d08c054c51 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts @@ -10,13 +10,14 @@ import { StructuredAgentSessionConversations } from './structured-agent-session- import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { hostTestAttachParams } from './structured-agent-session-host-test-data' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const journals = createTrackedJournalOpener() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index 7e2376150891..edba26f241b9 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 @@ -47,6 +47,7 @@ import { } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const PROVIDER_SESSION = 'provider-session-alpha-1' /** The tool call's row: the last thing the provider wrote before the crash. */ @@ -70,7 +71,7 @@ function crashedClaudeRecord(): AgentSessionRecord { providerHandleChain: [ { linkId, - handle: { provider: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null }, + handle: claudeProviderHandle(PROVIDER_SESSION, null), origin: 'created', mintedAtFence: 13, observedAt: TOOL_STARTED_AT - 60_000 @@ -119,7 +120,7 @@ async function seedClaudeToolTurn(): Promise { workspaceId: LOCATION.workspaceId, hostId: LOCATION.executionHostId, agent: 'claude', - providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null } + providerHandle: claudeProviderHandle(PROVIDER_SESSION, null) }, database: openTestJournalHostDatabase(root), now: () => now @@ -406,7 +407,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () process, link: { linkId: `claude-${fence}-link`, - handle: { provider: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null }, + handle: claudeProviderHandle(PROVIDER_SESSION, null), origin: 'resumed', mintedAtFence: fence, observedAt: RELAUNCHED_AT @@ -484,7 +485,7 @@ async function hostWithFailingFirstStart(failure: Error) { process, link: { linkId: `claude-${fence}-link`, - handle: { provider: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null }, + handle: claudeProviderHandle(PROVIDER_SESSION, null), origin: 'resumed', mintedAtFence: fence, observedAt: now diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index b39790e6b2af..8c3c48c93ada 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -17,6 +17,7 @@ import { unfinishedStructuredAgentSessionWorkWasInterrupted } from './structured-agent-session-dead-generation-settlement' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'The agent stopped while this response was in progress. You can continue in this conversation.' @@ -34,7 +35,7 @@ beforeEach(async () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, database: openTestJournalHostDatabase(root), now: () => 1_000 @@ -403,7 +404,7 @@ describe('dead structured-session generation settlement', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, database: openTestJournalHostDatabase(root), now: () => 1_000 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index b19c3db0c4f3..7b0e48bebadc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -25,6 +25,7 @@ import { openJournalOwingImport } from '../agent-session-journal/journal-owed-im import { createCodexJournalTranslator } from '../../codex/codex-structured-journal-translation' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { testEventSinkLogging } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const BODY: AgentJournalItemBody = { kind: 'message', @@ -37,7 +38,7 @@ const JOURNAL_IDENTITY = { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } as const function identity(ordinal: number): AgentJournalItemIdentity { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index fca04e1028ac..31e7bb718591 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -24,6 +24,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' @@ -44,7 +45,7 @@ beforeEach(async () => { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts index 38b85ca12d1f..d6590d6c61a7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts @@ -22,6 +22,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): claude: not signed in' @@ -38,7 +39,7 @@ beforeEach(async () => { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), // A resume continues the chain the first start created. origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index c0e78b5a0c52..912e54dc2e19 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -25,6 +25,7 @@ import type { StructuredAgentSessionAttachContext } from './structured-agent-ses import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' // Everything before the journal is out of scope here; what matters is what the orchestration does // when the attach throws after acquisition. @@ -43,7 +44,7 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'repo-1::/workspace/app', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: SESSION } + providerHandle: codexProviderHandle(SESSION) } const SUBJECT = makeStructuredAgentStatusSubject( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index b82499c28301..a4a12bfce7ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -25,6 +25,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -125,7 +126,7 @@ beforeEach(async () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts index b282fc4df323..49fb06e83bad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts @@ -15,12 +15,7 @@ export function structuredAgentSessionProviderSessionMetadata( record: AgentSessionRecord | null ): AgentProviderSessionMetadata | undefined { const head = record ? agentSessionProviderHandleChainHead(record.providerHandleChain) : null - return head - ? { - key: 'session_id', - id: head.handle.provider === 'claude' ? head.handle.sessionId : head.handle.threadId - } - : undefined + return head ? { key: 'session_id', id: head.handle.nativeId } : undefined } export function readStructuredAgentSessionHistoryResult(input: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts index ee0bb2bee929..c444f52c031e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts @@ -20,6 +20,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -50,7 +51,7 @@ describe('abandoning a structured agent-session host', () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index c7667531110f..96fc601134b4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -30,6 +30,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const journals = createTrackedJournalOpener() @@ -141,7 +142,7 @@ beforeEach(async () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index f66d2506804d..16251f3cd401 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -29,6 +29,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' let root: string let store: AgentSessionRecordStore @@ -111,7 +112,7 @@ describe('attach', () => { }, link: { linkId: 'stale-link', - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence + 1, observedAt: NOW @@ -126,7 +127,7 @@ describe('attach', () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index f91fc57c0558..81b7200c304e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -30,6 +30,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -108,7 +109,7 @@ beforeEach(async () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created' as const, mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts index c3eb1e324e9f..f428ab59a1bf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts @@ -10,6 +10,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const roots: string[] = [] @@ -56,7 +57,7 @@ async function liveStore(): Promise { fence: reserved.record.lease.runtimeFence, link: { linkId: 'link-renewal', - handle: { provider: 'codex', threadId: 'thread-renewal' }, + handle: codexProviderHandle('thread-renewal'), origin: 'created', mintedAtFence: reserved.record.lease.runtimeFence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index c25640cc81cc..00b9f617891d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -25,6 +25,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -125,7 +126,7 @@ beforeEach(async () => { process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? ('resumed' as const) : ('created' as const), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts index 9ab31743cf4b..b8bda4c1b78c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -38,6 +38,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } const PROVIDER_ROW = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1' } @@ -78,7 +79,7 @@ beforeEach(async () => { acquisitionGeneration: 'generation-1', link: { linkId: `link-${input.fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created' as const, mintedAtFence: input.fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index 203e31f4ea05..d46e2f7d38d1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -18,6 +18,7 @@ import { import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { sendPlan } from './structured-agent-session-mutation-plans' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' async function context(): Promise { return { @@ -29,7 +30,7 @@ async function context(): Promise { workspaceId: 'workspace', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, stateDirectory: join(hostTestState().root, 'settlement') }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 8a9d93c35e73..624828702713 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -22,6 +22,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } const DEFAULT_MODEL = 'gpt-default' @@ -66,7 +67,7 @@ function adapter(): StructuredAgentSessionAdapter { }, link: { linkId: `native-link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: acquire.mock.calls.length === 1 ? 'created' : 'resumed', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index c45ceac238c8..1c55baa57b0f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -16,6 +16,7 @@ import { openTestAttachConversation } from './structured-agent-session-attach-te import { performAttach } from './structured-agent-session-attach-flow' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const SESSION = 'session-alpha' @@ -114,13 +115,14 @@ describe('processless structured session reservation', () => { .mockReturnValueOnce(true) .mockReturnValueOnce(true) .mockReturnValueOnce(false) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attach reaches only `supportsCreate` and `acquire` on this fake adapter. const adapter = { supportsCreate, acquire: vi.fn(async ({ fence, spawnToken }) => ({ process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, link: { linkId: 'link-1', - handle: { provider: 'codex' as const, threadId: 'thread-1' }, + handle: codexProviderHandle('thread-1'), origin: 'created' as const, mintedAtFence: fence, observedAt: NOW @@ -263,6 +265,7 @@ describe('processless structured session reservation', () => { it('does not rerun a settled pre-spawn failure and admits a fresh operation', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-retry-')) const store = await openTestAgentSessionRecordStore(root) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attach reaches only the members this fake adapter defines. const adapter = { acquire: vi .fn() @@ -276,7 +279,7 @@ describe('processless structured session reservation', () => { }, link: { linkId: 'link-1', - handle: { provider: 'codex', threadId: 'thread-1' }, + handle: codexProviderHandle('thread-1'), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts index 3fed3c962919..2fa56d3fc6f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -14,13 +14,14 @@ import { performCancel, type AgentSessionTurnContext } from './structured-agent- import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import type { AgentSessionPromptCancelRoute } from './structured-agent-session-adapter-stop' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const PROMPT_IDENTITY = { provider: 'codex' as const, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 6d42155fa386..c61d2bd117f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -41,6 +41,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { @@ -67,7 +68,7 @@ const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spa acquisitionGeneration: generation(), link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? ('resumed' as const) : ('created' as const), @@ -337,7 +338,7 @@ describe('settling an earlier child before the next one takes its message', () = workspaceId: HOST_TEST_LOCATION.workspaceId, hostId: HOST_TEST_LOCATION.executionHostId, agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, database: openTestJournalHostDatabase(root) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index de2951a6292a..57d249d2dbe4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -17,6 +17,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CLAUDE_SESSION = 'claude-session' const hosts: StructuredAgentSessionHost[] = [] @@ -34,7 +35,7 @@ function claudeAdapter(): StructuredAgentSessionAdapter { }, link: { linkId: `link-${fence}`, - handle: { provider: 'claude', sessionId: CLAUDE_SESSION, leafUuid: null }, + handle: claudeProviderHandle(CLAUDE_SESSION, null), origin: 'created', mintedAtFence: fence, observedAt: HOST_TEST_NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index f94d091a5f97..f1271f4e3395 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -27,6 +27,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } @@ -92,7 +93,7 @@ export async function createQueuedMessageTestRig( ...(options.starting ? { providerChildPhase: 'starting' as const } : {}), link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: resumes ? ('resumed' as const) : ('created' as const), mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts index 945f2cefbad2..361aae9d83f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts @@ -19,10 +19,12 @@ import { } from '../agent-session-journal/journal-host-database-test-support' import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'codex_read_restore_fixture' const WORKSPACE_ID = 'repo-1::/tmp/workspace' +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a literal record the read path only reads; its fields match the record shape. const RECORD = { schemaVersion: 2, sessionId: SESSION_ID, @@ -36,7 +38,7 @@ const RECORD = { providerHandleChain: [ { linkId: 'codex-1-thread-1', - handle: { provider: 'codex', threadId: 'thread-1' }, + handle: codexProviderHandle('thread-1'), origin: 'created', mintedAtFence: 1, observedAt: 1 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index e8dfe9f7f5e6..8acb047f2ccb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -31,6 +31,7 @@ import { StructuredAgentSessionStatusFeed } from './structured-agent-session-sta import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'recovered-turn-session' const THREAD = 'thread-1' @@ -60,7 +61,7 @@ async function sessionWithRunningTurn() { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, now: () => clock, stateDirectory: join(root, SESSION) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index 228fa21fbc24..314e4012199f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -23,6 +23,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -110,7 +111,7 @@ beforeEach(async () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, observedAt: NOW @@ -288,7 +289,7 @@ describe('recovery exits', () => { process: outgoing.process, link: { linkId: 'link-outgoing', - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: 1, observedAt: NOW @@ -333,7 +334,7 @@ describe('recovery exits', () => { process: replacement.process, link: { linkId: 'link-replacement', - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'resumed', mintedAtFence: 3, observedAt: NOW + 2 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts index 8e3be9d1f3d7..bdeaa1527844 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts @@ -15,6 +15,7 @@ import { resolveStructuredSessionRecovery, type StructuredSessionRecoveryResolutionDeps } from './structured-agent-session-recovery-resolution' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const MATCHED: AgentSessionOwnerProbe = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } @@ -81,7 +82,7 @@ async function liveOwner(store: AgentSessionRecordStore, pid = 4242) { fence, link: { linkId: 'link-recovery', - handle: { provider: 'codex', threadId: 'thread-recovery' }, + handle: codexProviderHandle('thread-recovery'), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index d5899c794e3c..e2b5071d0fa0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -30,6 +30,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } const METHODS = ['agentSession.setOption', 'agentSession.send'] as const @@ -72,7 +73,7 @@ async function createHarness(options: { attached?: boolean } = {}) { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts index a16ef1101a2f..6fc7b6c2d52a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts @@ -23,6 +23,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -51,7 +52,7 @@ beforeEach(async () => { acquisitionGeneration: 'generation-1', link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: fence > 1 ? ('resumed' as const) : ('created' as const), mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts index a2e843378a54..c14899af1128 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts @@ -17,6 +17,7 @@ import { } from './structured-agent-session-event-sink' import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' import { testEventSinkLogging } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const ROW: AgentJournalItemIdentity = { provider: 'orca', clientMessageId: 'row' } @@ -45,7 +46,7 @@ beforeEach(async () => { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } })) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 0045684ccb37..bd21fa3ff339 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -34,6 +34,7 @@ import { import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' export const REST_TEST_CALLER = { callerKey: 'client-1' } export const IDLE_MS = STRUCTURED_AGENT_SESSION_IDLE_MS @@ -145,7 +146,7 @@ export async function createRestTestRig( process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? ('resumed' as const) : ('created' as const), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts index 93c33146743b..fd13cc3adb5c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts @@ -13,6 +13,10 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { structuredAgentSessionResumableSet } from './structured-agent-session-restart-resume-set' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' export const SESSION = 'session-working-1' export const THREAD = 'thread-1' @@ -53,7 +57,7 @@ export function record(overrides: { chain?: AgentSessionRecord['providerHandleCh providerHandleChain: overrides.chain ?? [ { linkId: 'link-1', - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: 1, observedAt: NOW @@ -124,7 +128,7 @@ export function claudeRecord( providerHandleChain: [ { linkId: 'link-1', - handle: { provider: 'claude', sessionId: providerSessionId, leafUuid }, + handle: claudeProviderHandle(providerSessionId, leafUuid), origin: 'created', mintedAtFence: 1, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts index 6cde8d87b842..38a47a06ebea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -28,6 +28,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -40,7 +41,7 @@ function adapter(): StructuredAgentSessionAdapter { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 4fa2811472a4..20cd3b298d56 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -40,6 +40,7 @@ import { createStructuredAgentSessionRestartOfferWithdrawal } from './structured import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const { readOnlyOpens, openReadOnly } = vi.hoisted(() => ({ readOnlyOpens: new Array(), @@ -87,7 +88,7 @@ function recordFor(sessionId: string): AgentSessionRecord { providerHandleChain: [ { linkId: `codex-1-${sessionId}`, - handle: { provider: 'codex', threadId: `thread-${sessionId}` }, + handle: codexProviderHandle(`thread-${sessionId}`), origin: 'created', mintedAtFence: 1, observedAt: 1 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts index 49a116ec7f7a..a574010b34ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts @@ -15,6 +15,7 @@ import { } from '../../../shared/agent-session-record.test-fixture' import * as providerSupport from './structured-agent-session-provider-support' import { revealStructuredAgentSession } from './structured-agent-session-reveal' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' function recordFor(provider: 'claude' | 'codex', sessionId: string): AgentSessionRecord { const record = agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })) @@ -26,7 +27,7 @@ function recordFor(provider: 'claude' | 'codex', sessionId: string): AgentSessio ? [ { ...record.providerHandleChain[0]!, - handle: { provider: 'codex', threadId: `thread-${sessionId}` } + handle: codexProviderHandle(`thread-${sessionId}`) } ] : record.providerHandleChain diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index c7240eccd833..8d5d5d54cbb8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -27,6 +27,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const caller = { callerKey: 'desktop' } const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 } @@ -61,7 +62,7 @@ function adapter(): StructuredAgentSessionAdapter { mintedAtFence: input.fence, observedAt: HOST_TEST_NOW, origin: acquires === 1 ? 'created' : 'resumed', - handle: { provider: 'codex', threadId: THREAD } + handle: codexProviderHandle(THREAD) } } }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index dc8eb1117005..5ee4ca72f691 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -30,6 +30,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const caller = { callerKey: 'desktop' } let directory: string @@ -75,7 +76,7 @@ beforeEach(async () => { mintedAtFence: input.fence, observedAt: HOST_TEST_NOW, origin: acquires.length === 1 ? 'created' : 'resumed', - handle: { provider: 'codex', threadId: HOST_TEST_THREAD } + handle: codexProviderHandle(HOST_TEST_THREAD) } } }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index f95579d7750d..f8cc4ef26921 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -99,13 +99,14 @@ export async function rewindStructuredAgentSession( } const selected = snapshot.items.findIndex((item) => item.itemId === params.itemId) const key = selected === -1 ? null : parseAgentJournalItemKey(providerKey(params.itemId)) + // The head belongs to the record's provider: the record store refuses any other. const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle - if (!key || !head || key.provider !== head.provider) { + if (!key || !head || key.provider !== record.provider) { return rewindRefusal('invalid-target') } let boundary = selected - if (key.provider === 'codex' && head.provider === 'codex') { - if (key.threadId !== head.threadId) { + if (key.provider === 'codex') { + if (key.threadId !== head.nativeId) { return rewindRefusal('invalid-target') } boundary = snapshot.items.findIndex((item) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts index e170461095b8..55acadfc562f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -11,6 +11,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-a import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const journals = createTrackedJournalOpener() @@ -25,7 +26,7 @@ beforeEach(async () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: root }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index cf746e29b740..63b392e35290 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -25,6 +25,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -51,7 +52,7 @@ beforeEach(async () => { acquisitionGeneration: `generation-${++generation}`, link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? ('resumed' as const) : ('created' as const), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index 8c5e609b971b..6cf15ca088c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -30,6 +30,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -135,7 +136,7 @@ beforeEach(async () => { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), // A re-acquire resumes the thread the first child minted, as a real adapter does. origin: generation === 0 ? ('created' as const) : ('resumed' as const), mintedAtFence: fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index f14393df8740..8298ac67daf9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -27,6 +27,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -98,7 +99,7 @@ beforeEach(async () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, observedAt: NOW @@ -185,7 +186,7 @@ describe('settled attach retry', () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW @@ -229,7 +230,7 @@ describe('settled attach retry', () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 8a9deaad48c0..90e5e84cf861 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -19,6 +19,7 @@ import { turnVerdictFromDeathEvidence, UNVERIFIABLE_TURN_VERDICT } from './structured-agent-session-stale-turn-verdict' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const THREAD = 'thread-1' const RUNNING_IDENTITY = { @@ -368,7 +369,7 @@ describe('stale session state on a cold acquire', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, stateDirectory: root, now: () => 1_000 @@ -422,7 +423,7 @@ describe('stale session state on a cold acquire', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, stateDirectory: root, now: () => now @@ -488,7 +489,7 @@ describe('stale session state on a cold acquire', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, stateDirectory: root, now: () => now @@ -559,7 +560,7 @@ describe('stale session state on a cold acquire', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, stateDirectory: root, now: () => now @@ -619,7 +620,7 @@ describe('stale session state on a cold acquire', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, stateDirectory: root, now: () => now diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 792276702381..0afc0a05077d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -28,6 +28,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -128,7 +129,7 @@ beforeEach(async () => { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: generation === 0 ? ('created' as const) : ('resumed' as const), mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts index cfb801c8fd6b..386040a05a60 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts @@ -11,6 +11,7 @@ import { } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'status-session' const EVIDENCE: AgentChildWorkEvidence[] = [{ type: 'session-ended', observedAt: 5 }] @@ -33,7 +34,7 @@ async function feedWith(sink: StructuredAgentSessionStatusSink) { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, SESSION) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts index a9c94cc4d2be..20a7fca25c14 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts @@ -7,6 +7,7 @@ import { createTrackedJournalOpener } from '../agent-session-journal/journal-hos import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'status-session' const journals = createTrackedJournalOpener() @@ -28,7 +29,7 @@ it('publishes the provider child startup phase, not which child it is', async () workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: root }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts index 30ddeea9678b..89e47785983a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts @@ -23,6 +23,7 @@ import { statusFeedChildView as childView } from './structured-agent-session-status-feed-test-session' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'status-session' const USER_IDENTITY = { @@ -61,7 +62,7 @@ async function feedWithChildren(provider: AgentSessionHandleProvider = 'codex') workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, SESSION) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts index 09a3442985b1..f7893ea0a449 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts @@ -15,6 +15,7 @@ import { createTrackedJournalOpener } from '../agent-session-journal/journal-hos import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'clock-session' const THREAD = 'thread-1' @@ -55,7 +56,7 @@ async function openFeed() { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, now: () => (clock += 100), stateDirectory: join(root, SESSION) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index 743a8e2287c4..f77086176fd9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -25,6 +25,7 @@ import { } from './structured-agent-session-status-feed' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { testEventSinkLogging } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'status-session' const TURN_IDENTITY = { @@ -61,7 +62,7 @@ async function openJournal(sessionId = SESSION, now?: () => number) { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, now, stateDirectory: join(root, sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts index aea5e93168a9..611b27f08f9a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts @@ -14,6 +14,7 @@ import { type StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'reenter-session' const journals = createTrackedJournalOpener() @@ -35,7 +36,7 @@ async function openJournal(): Promise { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, SESSION) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts index d47efcd4cad6..3dcc899b0819 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts @@ -22,6 +22,7 @@ import { StructuredAgentSessionStatusFeed } from './structured-agent-session-sta import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { testEventSinkLogging } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'recency-session' const CODEX_THREAD = 'thread-parent' @@ -54,7 +55,7 @@ async function openSession() { workspaceId: 'workspace-1', hostId: 'local', agent: 'claude', - providerHandle: { kind: 'codex', threadId: CODEX_THREAD } + providerHandle: codexProviderHandle(CODEX_THREAD) }, now: tick, stateDirectory: join(root, SESSION) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index 3881e8c429f2..239663f608c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -24,6 +24,10 @@ import { StructuredAgentSessionStatusFeed } from './structured-agent-session-sta import { MAX_RETAINED_SESSION_ACTIVITIES } from './structured-agent-session-activity-retention' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'subscriber-session' @@ -47,7 +51,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, 'activity-churn-journal') }) @@ -67,7 +71,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, 'checkpoint-journal') }) @@ -106,7 +110,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, 'clock-journal') }) @@ -151,7 +155,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, 'catalog-journal') }) @@ -194,7 +198,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, 'hook-journal') }) @@ -223,7 +227,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, 'unread-journal') }) @@ -289,7 +293,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: null } + providerHandle: claudeProviderHandle('provider-1', null) }, stateDirectory: join(root, 'background-journal') }) @@ -338,7 +342,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, 'activity-journal') }) @@ -390,7 +394,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: journalDir }) @@ -441,7 +445,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: journalDir }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 1f8fc80c863d..cce95e1ac0b9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -41,6 +41,7 @@ import { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'Codex stopped while this response was in progress. You can continue in this conversation.' @@ -221,7 +222,7 @@ beforeEach(async () => { acquisitionGeneration: `generation-${++generation}`, link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? ('resumed' as const) : ('created' as const), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index 6349e7b8ef60..5ab883380d54 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -17,13 +17,14 @@ import { } from './structured-agent-session-thread-goal' import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } let root: string | null = null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index b2f30032aa90..0cbf5b8ce6c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -12,13 +12,14 @@ import { createDeferredStructuredAgentSessionEventSink } from './structured-agen import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { testEventSinkLogging } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } let root: string | null = null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 08532f63c1fd..242f7c228778 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -51,6 +51,10 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } const DEAD_OWNER: AgentSessionProcessIdentity = { @@ -159,7 +163,7 @@ beforeEach(async () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'resumed' as const, mintedAtFence: fence, observedAt: NOW @@ -196,8 +200,8 @@ async function seedRunningTurn(provider: 'codex' | 'claude' = 'codex'): Promise< agent: provider, providerHandle: provider === 'codex' - ? { kind: 'codex', threadId: THREAD } - : { kind: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null } + ? codexProviderHandle(THREAD) + : claudeProviderHandle('provider-session-alpha-1', null) }, database: openTestJournalHostDatabase(root) }) @@ -604,7 +608,7 @@ describe('already-wedged profiles become usable on load', () => { // The conversation survived: the codex thread was resumed, not recreated. expect(store.getRecord(SESSION)?.providerHandleChain[0]).toMatchObject({ linkId: 'codex-13-link', - handle: { threadId: THREAD } + handle: { nativeId: THREAD } }) // Why NOT acquired here: startup spawning a provider child for every recovered record is the // accumulation this stack removed. Unlatching is the migration's job; spawning is a hold's. @@ -679,7 +683,7 @@ describe('already-wedged profiles become usable on load', () => { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-new' }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'resumed' as const, mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts index 24ae62a872bb..42cc18f923c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts @@ -22,6 +22,7 @@ import { } from '../agent-session-journal/journal-host-database-test-support' import { readAgentSessionHistory } from './agent-session-history-page' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'wire-admission-session' const LARGE_TEXT = 'x'.repeat(250 * 1024) @@ -38,7 +39,7 @@ beforeEach(async () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: root }) @@ -190,7 +191,7 @@ async function reopenWithOversizedRemoval(afterSequence: number): Promise { observedAt: HOST_TEST_NOW, // A start with no thread to resume creates one, whatever its fence. origin: - input.fence > 1 && input.identity.providerHandle.kind === 'codex' + input.fence > 1 && input.identity.providerHandle ? ('resumed' as const) : ('created' as const), - handle: { - provider: 'codex' as const, - threadId: - input.identity.providerHandle.kind === 'codex' - ? input.identity.providerHandle.threadId - : `00000000-0000-4000-8000-${String(acquisitions).padStart(12, '0')}` - } + handle: codexProviderHandle( + input.identity.providerHandle?.nativeId ?? + `00000000-0000-4000-8000-${String(acquisitions).padStart(12, '0')}` + ) } } }), @@ -504,11 +502,7 @@ describe("the replacement's first send", () => { .mocked(adapter.acquire) .mock.calls.flatMap(([input]) => (input.identity.sessionId === replacement ? [input] : [])) // No provider conversation to resume: the source's thread is not carried over. - expect(start?.identity.providerHandle).toEqual({ - kind: 'opaque', - agent: 'codex', - value: 'pending' - }) + expect(start?.identity.providerHandle).toBeNull() expect(start?.fence).toBe(2) expect(store.getRecord(replacement)?.lease).toMatchObject({ claimStatus: 'live' }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 22766f28a416..41031cb3b3eb 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -33,6 +33,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import type { StructuredConversationCommandOutcome } from './structured-conversation-command-outcome' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' let state: ReturnType let compact: Mock> @@ -589,7 +590,7 @@ it('writes one exit row when the child dies mid-command, and the loop writes not acquisitionGeneration: `generation-${fence}`, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), // The next child resumes the thread, as a real one does. origin: state.store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, @@ -639,7 +640,7 @@ it('delivers the next message after a command whose child died and whose settlem acquisitionGeneration: `generation-${fence}`, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: state.store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, observedAt: 1 diff --git a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts index ad2119024339..d34bb5ac87fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts @@ -16,8 +16,7 @@ export function listStructuredProviderSessionOwnership( sessionId: record.sessionId, workspaceId: record.location.workspaceId, provider: record.provider, - providerSessionId: - link.handle.provider === 'codex' ? link.handle.threadId : link.handle.sessionId, + providerSessionId: link.handle.nativeId, lease: record.lease })) ) diff --git a/src/main/native-chat/structured-agent-session-history-adoption.test.ts b/src/main/native-chat/structured-agent-session-history-adoption.test.ts index 581bb29c3649..cdfb26842eb6 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.test.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.test.ts @@ -10,6 +10,10 @@ import { structuredAdoptionConflictError, type StructuredAgentSessionAdoptionOwnership } from './structured-agent-session-history-adoption' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../shared/agent-session-provider-handle-encoding' const OPERATION = '1800000000000-00000000000000000000000000000001' @@ -30,7 +34,7 @@ function committedReplay(overrides: { callerKey?: string; operationId?: string } origin: 'adopted', mintedAtFence: 1, observedAt: 1_800_000_000_000, - handle: { provider: 'codex', threadId: 'thread-1' } + handle: codexProviderHandle('thread-1') } ], accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex-original' } @@ -128,11 +132,7 @@ describe('findCommittedStructuredAgentSessionAdoptionReplay', () => { record.providerHandleChain[0] = { ...record.providerHandleChain[0]!, origin: 'adopted', - handle: { - provider: 'claude', - sessionId: 'provider-session-alpha-1', - leafUuid: 'leaf-1' - } + handle: claudeProviderHandle('provider-session-alpha-1', 'leaf-1') } expect( diff --git a/src/main/native-chat/structured-agent-session-history-adoption.ts b/src/main/native-chat/structured-agent-session-history-adoption.ts index e64e8f799d8f..662b93dcb2c0 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -7,7 +7,10 @@ import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' -import type { AgentSessionProviderHandle } from '../../shared/agent-session-journal-types' +import { + agentSessionWireProviderHandle, + type AgentSessionWireProviderHandle +} from '../../shared/agent-session-provider-handle-encoding' import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease-adjudication' @@ -26,7 +29,7 @@ export type StructuredAgentSessionAdoption = { export type CommittedStructuredAgentSessionAdoptionReplay = { record: AgentSessionRecord - providerHandle: Exclude + providerHandle: AgentSessionWireProviderHandle } /** Exact committed-operation identity; attach still validates its fingerprint. */ @@ -58,22 +61,12 @@ export function findCommittedStructuredAgentSessionAdoptionReplay(input: { ) { return null } - const providerSessionId = - adopted.handle.provider === 'codex' ? adopted.handle.threadId : adopted.handle.sessionId - if (providerSessionId !== input.providerSessionId) { + // The replay re-sends the adoption as its create sent it, so it needs the wire's form. + const providerHandle = agentSessionWireProviderHandle(adopted.handle) + if (adopted.handle.nativeId !== input.providerSessionId || !providerHandle) { return null } - return { - record, - providerHandle: - adopted.handle.provider === 'codex' - ? { kind: 'codex', threadId: adopted.handle.threadId } - : { - kind: 'claude', - sessionId: adopted.handle.sessionId, - leafUuid: adopted.handle.leafUuid - } - } + return { record, providerHandle } } /** diff --git a/src/main/runtime/agent-session-death-evidence-persistence.test.ts b/src/main/runtime/agent-session-death-evidence-persistence.test.ts index 9904ca7994f4..73d01b5b88e8 100644 --- a/src/main/runtime/agent-session-death-evidence-persistence.test.ts +++ b/src/main/runtime/agent-session-death-evidence-persistence.test.ts @@ -13,6 +13,7 @@ import { openTestAgentSessionRecordStore, seedTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION = 'session-alpha-1' /** The fixture lease's last renewal. */ @@ -163,7 +164,7 @@ describe('a failed acquisition', () => { fence, link: { linkId: 'link-1', - handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: null }, + handle: claudeProviderHandle('provider-session-1', null), origin: 'created', mintedAtFence: fence, observedAt: NOW @@ -203,7 +204,7 @@ describe('a failed acquisition', () => { fence, link: { linkId: 'link-1', - handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: null }, + handle: claudeProviderHandle('provider-session-1', null), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/runtime/agent-session-lease-renewal.test.ts b/src/main/runtime/agent-session-lease-renewal.test.ts index ec636a96eed5..a20143780ec1 100644 --- a/src/main/runtime/agent-session-lease-renewal.test.ts +++ b/src/main/runtime/agent-session-lease-renewal.test.ts @@ -8,6 +8,7 @@ import { openTestAgentSessionRecordStore, readPersistedTestAgentSessionStoreText } from './agent-session-record-store-test-harness' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const MATCHED = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } as const @@ -54,7 +55,7 @@ async function establishOwner( fence, link: { linkId: `link-${suffix}`, - handle: { provider: 'codex', threadId: `thread-${suffix}` }, + handle: codexProviderHandle(`thread-${suffix}`), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 0b5b6e5f2efd..1737ad45d3fd 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -16,6 +16,7 @@ import { appendAgentSessionProviderHandleLink, type AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { agentSessionProviderHandleBelongsTo } from '../../shared/agent-session-provider-handle-encoding' import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import type { AgentSessionDeathEvidence, @@ -145,7 +146,7 @@ export function proveAgentSessionOwner(args: { reason: 'spawnIdentityMismatch' }) } - if (args.link.handle.provider !== record.provider) { + if (!agentSessionProviderHandleBelongsTo(args.link.handle, record.provider)) { throw new Error('agent_session_provider_handle_provider_mismatch') } if (args.link.mintedAtFence !== args.fence) { diff --git a/src/main/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts index bf2156d0710b..3a9e47fe6c29 100644 --- a/src/main/runtime/agent-session-legacy-record-import.test.ts +++ b/src/main/runtime/agent-session-legacy-record-import.test.ts @@ -11,6 +11,10 @@ import { type AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' +import { + encodePersistedAgentSessionProviderHandle, + isAgentSessionProviderHandle +} from '../../shared/agent-session-provider-handle-encoding' import { agentSessionLeaseFixture, agentSessionRecordFixture @@ -66,6 +70,24 @@ const OPERATION: AgentSessionOperationRow = { outcome: { status: 'succeeded', sessionId: ALPHA } } +/** The records file only ever held handles in their stored form. */ +function storedForm(value: Record): Record { + const chain = value.providerHandleChain + return Array.isArray(chain) + ? { + ...value, + providerHandleChain: chain.map((link: unknown) => + typeof link === 'object' && + link !== null && + 'handle' in link && + isAgentSessionProviderHandle(link.handle) + ? { ...link, handle: encodePersistedAgentSessionProviderHandle(link.handle) } + : link + ) + } + : value +} + function legacyFile( records: readonly (Record & { sessionId: string })[], extra: Record = {} @@ -73,7 +95,7 @@ function legacyFile( return { schemaVersion: 2, hostId: 'local', - records: Object.fromEntries(records.map((value) => [value.sessionId, value])), + records: Object.fromEntries(records.map((value) => [value.sessionId, storedForm(value)])), operations: {}, retiredClaimKeys: [], unusableRecords: {}, @@ -246,7 +268,7 @@ describe('a record the file set aside', () => { const stored = database.db .prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?') .get(ALPHA) - expect(JSON.parse(String(stored?.record_json))).toEqual(unsupported) + expect(JSON.parse(String(stored?.record_json))).toEqual(storedForm(unsupported)) }) // #23589: both copies exist only when an older build recovered the readable one from its backup, @@ -385,7 +407,7 @@ describe('the tab index from before the table', () => { const owed = await install() owed.database.db .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') - .run(BETA, JSON.stringify(record(BETA))) + .run(BETA, JSON.stringify(storedForm(record(BETA)))) await AgentSessionRecordStore.open({ journalDatabase: owed.database, hostId: 'local' diff --git a/src/main/runtime/agent-session-legacy-record-import.ts b/src/main/runtime/agent-session-legacy-record-import.ts index 0a0b4d49d5bb..f632f008e764 100644 --- a/src/main/runtime/agent-session-legacy-record-import.ts +++ b/src/main/runtime/agent-session-legacy-record-import.ts @@ -9,6 +9,7 @@ import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { encodeAgentSessionRecord } from '../../shared/agent-session-record-stored-form' import { NO_LEGACY_JOURNAL_RECORDS, type JournalLegacyRecordImport @@ -106,11 +107,15 @@ function importRows( for (const [sessionId, loaded] of records) { const setAside = state.unreadableRecords.get(sessionId) const record = setAside ? withFloorAboveSetAsideCopy(loaded, setAside.raw) : loaded - const json = JSON.stringify({ ...record, lease: withoutRetiredLeaseLatches(record.lease) }) + const json = JSON.stringify( + encodeAgentSessionRecord({ ...record, lease: withoutRetiredLeaseLatches(record.lease) }) + ) // A record the load rules refuse is kept as its bytes, which every load then sets aside. rows.push([ sessionId, - isReadableAgentSessionStoreRecord(sessionId, JSON.parse(json)) ? json : JSON.stringify(loaded) + isReadableAgentSessionStoreRecord(sessionId, JSON.parse(json)) + ? json + : JSON.stringify(encodeAgentSessionRecord(loaded)) ]) } for (const [sessionId, { raw }] of state.unreadableRecords) { diff --git a/src/main/runtime/agent-session-provider-handle-transition.test.ts b/src/main/runtime/agent-session-provider-handle-transition.test.ts index 7a61ea7bdfab..fa3eea6ecf6d 100644 --- a/src/main/runtime/agent-session-provider-handle-transition.test.ts +++ b/src/main/runtime/agent-session-provider-handle-transition.test.ts @@ -5,15 +5,20 @@ import { } from '../../shared/agent-session-record.test-fixture' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record' +import { encodeAgentSessionRecord } from '../../shared/agent-session-record-stored-form' import { recordAgentSessionProviderHandle, - reviseAgentSessionClaudeResumePoint + reviseAgentSessionProviderResumePoint } from './agent-session-provider-handle-transition' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../shared/agent-session-provider-handle-encoding' function resumedLink(fence: number): AgentSessionProviderHandleLink { return { linkId: 'link-2', - handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: 'leaf-2' }, + handle: claudeProviderHandle('provider-session-alpha-1', 'leaf-2'), origin: 'resumed', mintedAtFence: fence, observedAt: 4_000 @@ -29,7 +34,7 @@ describe('recordAgentSessionProviderHandle', () => { link: resumedLink(record.lease.runtimeFence), now: 4_000 }) - expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'leaf-2' }) + expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ providerData: 'leaf-2' }) expect(next.lease.provenHandleLinkId).toBe('link-2') }) @@ -46,18 +51,17 @@ describe('recordAgentSessionProviderHandle', () => { link: resumedLink(lease.runtimeFence), now: 4_000 }) - expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'leaf-2' }) + expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ providerData: 'leaf-2' }) expect(next.lease).toMatchObject({ claimStatus: 'reserved', provenHandleLinkId: null }) }) }) -describe('reviseAgentSessionClaudeResumePoint', () => { +describe('reviseAgentSessionProviderResumePoint', () => { const revise = (record = agentSessionRecordFixture(), leafUuid = 'leaf-2') => - reviseAgentSessionClaudeResumePoint({ + reviseAgentSessionProviderResumePoint({ record, fence: record.lease.runtimeFence, - providerSessionId: 'provider-session-alpha-1', - leafUuid, + handle: claudeProviderHandle('provider-session-alpha-1', leafUuid), now: 5_000 }) @@ -68,20 +72,19 @@ describe('reviseAgentSessionClaudeResumePoint', () => { expect(second.providerHandleChain.at(-1)).toMatchObject({ linkId: 'link-1', origin: 'created', - handle: { leafUuid: 'leaf-3' } + handle: { providerData: 'leaf-3' } }) expect(second.lease.provenHandleLinkId).toBe('link-1') - expect(isPersistedAgentSessionRecord(second)).toBe(true) + expect(isPersistedAgentSessionRecord(encodeAgentSessionRecord(second))).toBe(true) }) it('refuses a stale owner, a released lease, and a head minted by another owner', () => { const record = agentSessionRecordFixture() expect(() => - reviseAgentSessionClaudeResumePoint({ + reviseAgentSessionProviderResumePoint({ record, fence: record.lease.runtimeFence - 1, - providerSessionId: 'provider-session-alpha-1', - leafUuid: 'leaf-2', + handle: claudeProviderHandle('provider-session-alpha-1', 'leaf-2'), now: 5_000 }) ).toThrow('agent_session_stale_fence') @@ -96,11 +99,19 @@ describe('reviseAgentSessionClaudeResumePoint', () => { }) ).toThrow('agent_session_provider_handle_invalid') expect(() => - reviseAgentSessionClaudeResumePoint({ + reviseAgentSessionProviderResumePoint({ + record, + fence: record.lease.runtimeFence, + handle: claudeProviderHandle('another-provider-session', 'leaf-2'), + now: 5_000 + }) + ).toThrow('agent_session_provider_handle_invalid') + // Same id, another provider's namespace: a different conversation, never this one's leaf. + expect(() => + reviseAgentSessionProviderResumePoint({ record, fence: record.lease.runtimeFence, - providerSessionId: 'another-provider-session', - leafUuid: 'leaf-2', + handle: codexProviderHandle('provider-session-alpha-1'), now: 5_000 }) ).toThrow('agent_session_provider_handle_invalid') diff --git a/src/main/runtime/agent-session-provider-handle-transition.ts b/src/main/runtime/agent-session-provider-handle-transition.ts index 57d2ecb172c0..a2c942441984 100644 --- a/src/main/runtime/agent-session-provider-handle-transition.ts +++ b/src/main/runtime/agent-session-provider-handle-transition.ts @@ -1,10 +1,14 @@ import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { agentSessionProviderHandleChainHead, + agentSessionProviderHandleRoot, + agentSessionProviderHandlesEqual, appendAgentSessionProviderHandleLink, isAgentSessionProviderHandleChain, + type AgentSessionProviderHandle, type AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { agentSessionProviderHandleBelongsTo } from '../../shared/agent-session-provider-handle-encoding' import type { AgentSessionRecord } from '../../shared/agent-session-record' export function recordAgentSessionProviderHandle(args: { @@ -17,7 +21,10 @@ export function recordAgentSessionProviderHandle(args: { if (record.lease.runtimeFence !== args.fence) { throw new Error('agent_session_stale_fence') } - if (args.link.handle.provider !== record.provider || args.link.mintedAtFence !== args.fence) { + if ( + !agentSessionProviderHandleBelongsTo(args.link.handle, record.provider) || + args.link.mintedAtFence !== args.fence + ) { throw new Error('agent_session_provider_handle_invalid') } if (record.lease.claimStatus !== 'live' && record.lease.handoffStage !== 'new-owner-proving') { @@ -44,14 +51,15 @@ export function recordAgentSessionProviderHandle(args: { } /** - * Advance the live owner's Claude resume point in place. The head link this owner minted keeps - * its id and provenance; only its leaf moves, so a long conversation does not grow the chain. + * Move the live owner's resume point in place: the head link this owner minted keeps its id and + * provenance, and only the adapter-owned data its handle carries changes, so a long conversation + * does not grow the chain. The adapter decides what that data means; this only refuses a handle + * that names another conversation. */ -export function reviseAgentSessionClaudeResumePoint(args: { +export function reviseAgentSessionProviderResumePoint(args: { record: AgentSessionRecord fence: number - providerSessionId: string - leafUuid: string + handle: AgentSessionProviderHandle now: number }): AgentSessionRecord { const { record } = args @@ -63,18 +71,18 @@ export function reviseAgentSessionClaudeResumePoint(args: { } const head = agentSessionProviderHandleChainHead(record.providerHandleChain) if ( - head?.handle.provider !== 'claude' || - head.handle.sessionId !== args.providerSessionId || + !head || + agentSessionProviderHandleRoot(head.handle) !== agentSessionProviderHandleRoot(args.handle) || head.mintedAtFence !== args.fence ) { throw new Error('agent_session_provider_handle_invalid') } - if (head.handle.leafUuid === args.leafUuid) { + if (agentSessionProviderHandlesEqual(head.handle, args.handle)) { return record } const providerHandleChain = [ ...record.providerHandleChain.slice(0, -1), - { ...head, handle: { ...head.handle, leafUuid: args.leafUuid }, observedAt: args.now } + { ...head, handle: args.handle, observedAt: args.now } ] // The revised chain must still read back as the same persisted chain. if (!isAgentSessionProviderHandleChain(providerHandleChain)) { diff --git a/src/main/runtime/agent-session-record-conversation-name.test.ts b/src/main/runtime/agent-session-record-conversation-name.test.ts index 084cc93ac4d4..f9a3cfb3c91d 100644 --- a/src/main/runtime/agent-session-record-conversation-name.test.ts +++ b/src/main/runtime/agent-session-record-conversation-name.test.ts @@ -1,14 +1,28 @@ import { describe, expect, it } from 'vitest' -import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record' +import { + isPersistedAgentSessionRecord, + type AgentSessionRecord +} from '../../shared/agent-session-record' +import { encodePersistedAgentSessionProviderHandleChain } from '../../shared/agent-session-provider-handle' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' const NOW = 9_000 +/** Validates the record as its row stores it. */ +function storedRecordIsValid( + record: Omit & { conversationName?: unknown } +): boolean { + return isPersistedAgentSessionRecord({ + ...record, + providerHandleChain: encodePersistedAgentSessionProviderHandleChain(record.providerHandleChain) + }) +} + describe('agent session record conversationName validation', () => { it('accepts a record carrying a bounded name', () => { expect( - isPersistedAgentSessionRecord({ + storedRecordIsValid({ ...agentSessionRecordFixture(), conversationName: 'Fix the lease probe' }) @@ -16,12 +30,12 @@ describe('agent session record conversationName validation', () => { }) it('accepts a record with no name at all', () => { - expect(isPersistedAgentSessionRecord(agentSessionRecordFixture())).toBe(true) + expect(storedRecordIsValid(agentSessionRecordFixture())).toBe(true) }) it('rejects a name past the stored maximum', () => { expect( - isPersistedAgentSessionRecord({ + storedRecordIsValid({ ...agentSessionRecordFixture(), conversationName: 'a'.repeat(201) }) @@ -29,23 +43,23 @@ describe('agent session record conversationName validation', () => { }) it('rejects a name that is not a string', () => { - expect( - isPersistedAgentSessionRecord({ ...agentSessionRecordFixture(), conversationName: 42 }) - ).toBe(false) - expect( - isPersistedAgentSessionRecord({ ...agentSessionRecordFixture(), conversationName: '' }) - ).toBe(false) + expect(storedRecordIsValid({ ...agentSessionRecordFixture(), conversationName: 42 })).toBe( + false + ) + expect(storedRecordIsValid({ ...agentSessionRecordFixture(), conversationName: '' })).toBe( + false + ) }) it('rejects persisted names that bypassed canonical normalization', () => { expect( - isPersistedAgentSessionRecord({ + storedRecordIsValid({ ...agentSessionRecordFixture(), conversationName: 'Fix\u202Egnp.exe probe' }) ).toBe(false) expect( - isPersistedAgentSessionRecord({ + storedRecordIsValid({ ...agentSessionRecordFixture(), conversationName: 'Fix\nthe probe' }) @@ -63,7 +77,7 @@ describe('setAgentSessionRecordConversationName', () => { expect(next.conversationName).toBe('Fix the lease probe') expect(next.updatedAt).toBe(NOW) - expect(isPersistedAgentSessionRecord(next)).toBe(true) + expect(storedRecordIsValid(next)).toBe(true) }) it('normalizes on the way in so the record stays valid whatever the caller sent', () => { @@ -74,7 +88,7 @@ describe('setAgentSessionRecordConversationName', () => { ) expect(next.conversationName).toBe('Fix the probe') - expect(isPersistedAgentSessionRecord(next)).toBe(true) + expect(storedRecordIsValid(next)).toBe(true) }) it('bounds an over-long name rather than storing a record the validator would reject', () => { @@ -85,7 +99,7 @@ describe('setAgentSessionRecordConversationName', () => { ) expect(next.conversationName).toHaveLength(200) - expect(isPersistedAgentSessionRecord(next)).toBe(true) + expect(storedRecordIsValid(next)).toBe(true) }) it('clears the name via null, deleting the key rather than storing an empty string', () => { @@ -99,7 +113,7 @@ describe('setAgentSessionRecordConversationName', () => { expect(Object.hasOwn(cleared, 'conversationName')).toBe(false) expect(cleared.updatedAt).toBe(NOW + 1) - expect(isPersistedAgentSessionRecord(cleared)).toBe(true) + expect(storedRecordIsValid(cleared)).toBe(true) }) it('treats a name that normalizes to nothing as a clear', () => { diff --git a/src/main/runtime/agent-session-record-options.test.ts b/src/main/runtime/agent-session-record-options.test.ts index 58385e292726..c092018ea276 100644 --- a/src/main/runtime/agent-session-record-options.test.ts +++ b/src/main/runtime/agent-session-record-options.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, expect, it } from 'vitest' import { readNativeSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const SESSION = 'session-options' @@ -96,7 +97,7 @@ it('persists resumed provider options atomically with owner proof', async () => fence, link: { linkId: 'codex-options-1', - handle: { provider: 'codex', threadId: 'thread-options' }, + handle: codexProviderHandle('thread-options'), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/runtime/agent-session-record-rows.ts b/src/main/runtime/agent-session-record-rows.ts index be6f70f06e7d..1b1fe6e1b3b2 100644 --- a/src/main/runtime/agent-session-record-rows.ts +++ b/src/main/runtime/agent-session-record-rows.ts @@ -9,7 +9,7 @@ import { isPersistedAgentSessionRecord, type AgentSessionLease } from '../../shared/agent-session-record' -import { normalizeLegacyHandoffRecord } from '../../shared/agent-session-legacy-handoff-lease' +import { decodePersistedAgentSessionRecord } from '../../shared/agent-session-record-stored-form' import type Database from '../sqlite/sync-database' import type { SqliteRow } from '../sqlite/sqlite-statement' import { @@ -112,7 +112,7 @@ export function loadAgentSessionStoreRows( const parsed = parseJson(text(row, 'record_json')) const value = parsed.ok ? parsed.value : text(row, 'record_json') if (parsed.ok && isReadableAgentSessionStoreRecord(sessionId, value)) { - const { record } = normalizeLegacyHandoffRecord(value) + const { record } = decodePersistedAgentSessionRecord(value) state.records.set(sessionId, { ...record, lease: { ...withoutRetiredLeaseLatches(record.lease), unreconciled: true } diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index 79e65388fbd0..6b5632a87abf 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -16,7 +16,7 @@ import { isPersistedAgentSessionRecord, type AgentSessionRecord } from '../../shared/agent-session-record' -import { normalizeLegacyHandoffRecord } from '../../shared/agent-session-legacy-handoff-lease' +import { decodePersistedAgentSessionRecord } from '../../shared/agent-session-record-stored-form' import { parseAgentSessionTabTable, type AgentSessionTabTable } from './agent-session-tab-table' export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const @@ -130,7 +130,7 @@ function parseState(raw: string, hostId: string): Pick ({ + ...link, + handle: isAgentSessionProviderHandle(link.handle) + ? encodePersistedAgentSessionProviderHandle(link.handle) + : link.handle + })) + } +} + /** Leaves `records` behind as an earlier run of the app would have, before anything opens it. */ export async function seedTestAgentSessionRecordStore( stateDirectory: string, - seed: { records: readonly PersistedAgentSessionRecord[] } + seed: { records: readonly (AgentSessionRecord | PersistedAgentSessionRecord)[] } ): Promise { writePersisted(databaseFor(stateDirectory), { schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, hostId: TEST_HOST_ID, - records: Object.fromEntries(seed.records.map((record) => [record.sessionId, record])), + records: Object.fromEntries( + seed.records.map((record) => [record.sessionId, storedTestAgentSessionRecord(record)]) + ), operations: {}, retiredClaimKeys: [], unusableRecords: {} diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index 12aa70b330cd..078f9b6d0eca 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -21,6 +21,10 @@ import { import { AGENT_SESSION_CLAIM_KEY_RETENTION_MS } from './agent-session-claim-key-retention' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 @@ -87,7 +91,7 @@ function handleLink( ): AgentSessionProviderHandleLink { return { linkId: 'link-1', - handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: 'leaf-1' }, + handle: claudeProviderHandle('provider-session-1', 'leaf-1'), origin: 'created', mintedAtFence: 1, observedAt: NOW, @@ -218,7 +222,7 @@ describe('acquisition path', () => { sessionId: 'session-alpha', fence: 1, link: handleLink({ - handle: { provider: 'codex', threadId: 'thread-1' } + handle: codexProviderHandle('thread-1') }), now: NOW }) diff --git a/src/main/runtime/agent-session-released-reservation-replay.test.ts b/src/main/runtime/agent-session-released-reservation-replay.test.ts index e5f54265dcb7..8e02a2660afc 100644 --- a/src/main/runtime/agent-session-released-reservation-replay.test.ts +++ b/src/main/runtime/agent-session-released-reservation-replay.test.ts @@ -15,6 +15,7 @@ import type { AgentSessionLease } from '../../shared/agent-session-record' import type { AgentSessionRecordStore } from './agent-session-record-store' import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const SESSION = 'session-alpha' @@ -137,7 +138,7 @@ describe('a create retried after recovery released its reservation', () => { fence: 1, link: { linkId: 'link-1', - handle: { provider: 'codex', threadId: 'thread-1' }, + handle: codexProviderHandle('thread-1'), origin: 'created', mintedAtFence: 1, observedAt: NOW diff --git a/src/main/runtime/agent-session-reservation-admission.test.ts b/src/main/runtime/agent-session-reservation-admission.test.ts index cad534931e60..0729ebfc35b7 100644 --- a/src/main/runtime/agent-session-reservation-admission.test.ts +++ b/src/main/runtime/agent-session-reservation-admission.test.ts @@ -16,6 +16,10 @@ import { type AgentSessionReserveRequest } from './agent-session-reservation-admission' import type { AgentSessionStoreState } from './agent-session-record-store-file' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const LEASE_TTL_MS = 60_000 @@ -34,7 +38,7 @@ function adoptedLink( ): AgentSessionProviderHandleLink { return { linkId: 'claude-1-provider-session-alpha-1-empty', - handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null }, + handle: claudeProviderHandle('provider-session-alpha-1', null), origin: 'adopted', mintedAtFence: 1, observedAt: NOW, @@ -118,7 +122,7 @@ describe('adopted conversation ownership', () => { storeState([holder]), reserveRequest({ adoptedHandleLink: adoptedLink({ - handle: { provider: 'claude', sessionId: 'provider-session-other', leafUuid: null } + handle: claudeProviderHandle('provider-session-other', null) }) }), LEASE_TTL_MS @@ -170,7 +174,7 @@ describe('adopted conversation ownership', () => { providerHandleChain: [ { linkId: 'codex-1-thread-1', - handle: { provider: 'codex', threadId: 'thread-1' }, + handle: codexProviderHandle('thread-1'), origin: 'created', mintedAtFence: 7, observedAt: NOW @@ -187,7 +191,7 @@ describe('adopted conversation ownership', () => { accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, adoptedHandleLink: adoptedLink({ linkId: 'codex-1-thread-1-adopted', - handle: { provider: 'codex', threadId: 'thread-1' } + handle: codexProviderHandle('thread-1') }) }), LEASE_TTL_MS diff --git a/src/main/runtime/agent-session-store-draft.ts b/src/main/runtime/agent-session-store-draft.ts index 2afdfc13484b..e39027d0042a 100644 --- a/src/main/runtime/agent-session-store-draft.ts +++ b/src/main/runtime/agent-session-store-draft.ts @@ -3,6 +3,7 @@ * only once its rows have committed, so no reader ever sees a change that might still roll back. */ +import { encodeAgentSessionRecord } from '../../shared/agent-session-record-stored-form' import type { AgentSessionStoreState, RetiredAgentSessionClaimKey @@ -42,7 +43,8 @@ export type AgentSessionStoreRowWrites = { function serializeChangedRows( published: ReadonlyMap, next: ReadonlyMap, - readable: (key: string, written: unknown) => boolean + readable: (key: string, written: unknown) => boolean, + stored: (value: V) => unknown = (value) => value ): KeyedRowWrites { const writes: KeyedRowWrites = { upsert: [], remove: [] } let added = 0 @@ -51,7 +53,7 @@ function serializeChangedRows( if (prior === value) { continue } - const json = JSON.stringify(value) + const json = JSON.stringify(stored(value)) if (json === undefined || !readable(key, JSON.parse(json))) { throw new Error('agent_session_store_write_invalid') } @@ -85,8 +87,11 @@ export function agentSessionStoreDraftRowWrites( published: AgentSessionStoreState, draft: AgentSessionStoreState ): AgentSessionStoreRowWrites | null { - const records = serializeChangedRows(published.records, draft.records, (sessionId, written) => - isReadableAgentSessionStoreRecord(sessionId, written) + const records = serializeChangedRows( + published.records, + draft.records, + (sessionId, written) => isReadableAgentSessionStoreRecord(sessionId, written), + encodeAgentSessionRecord ) const operations = serializeChangedRows(published.operations, draft.operations, (key, written) => isReadableAgentSessionStoreOperation(key, written) diff --git a/src/main/runtime/agent-session-store-transactions.test.ts b/src/main/runtime/agent-session-store-transactions.test.ts index 5ab6cad9e028..6c297178e140 100644 --- a/src/main/runtime/agent-session-store-transactions.test.ts +++ b/src/main/runtime/agent-session-store-transactions.test.ts @@ -16,6 +16,7 @@ import { openTestAgentSessionRecordStore, readPersistedTestAgentSessionStore } from './agent-session-record-store-test-harness' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 @@ -76,7 +77,7 @@ async function liveChat( fence, link: { linkId: `link-${counter}`, - handle: { provider: 'claude', sessionId: `provider-${counter}`, leafUuid: null }, + handle: claudeProviderHandle(`provider-${counter}`, null), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts b/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts index 31e473fb5e66..a6f002170db4 100644 --- a/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts +++ b/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts @@ -7,6 +7,7 @@ import { codexProviderHandleLink } from '../codex/codex-structured-owner-identit import type { AgentSessionRecordStore } from './agent-session-record-store' import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const SESSION = 'session-codex' @@ -116,7 +117,7 @@ describe('a Codex thread started in place of one Codex never saved', () => { expect(proved.lease.claimStatus).toBe('live') expect(proved.providerHandleChain).toEqual([ expect.objectContaining({ - handle: { provider: 'codex', threadId: 'thread-new' }, + handle: codexProviderHandle('thread-new'), origin: 'created', supersedesKey: 'codex:"thread-unsaved"' }) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index a12fc06c479e..92d431293d3d 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -28,6 +28,7 @@ import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION = 'claude-integration-1' const PROVIDER_SESSION = claudeSessionIdForOrcaSession(SESSION) @@ -768,19 +769,20 @@ describe('a structured Claude session over agentSession.*', () => { ).resolves.toMatchObject({ turnId: 'provider-opened-assistant', cancelled: true }) expect(claude.live().calls.at(-1)).toMatchObject({ subtype: 'interrupt' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test-only view of the host's store; only `getRecord` is read. const host = getStructuredAgentSessionHost() as unknown as { deps: { store: { getRecord: (sessionId: string) => { - providerHandleChain: { handle: { provider: string; leafUuid?: string | null } }[] + providerHandleChain: { handle: { transport: string; providerData?: string } }[] } } } } // A completed turn advances the durable resume point in place while the owner is live. expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)?.handle).toMatchObject({ - provider: 'claude', - leafUuid: 'assistant-leaf' + transport: 'claude-sdk', + providerData: 'assistant-leaf' }) // A Claude Stop ends its child once Claude ends the stopped turn, so the chat rests; the next // open resumes the conversation. @@ -806,11 +808,7 @@ describe('a structured Claude session over agentSession.*', () => { expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION }) expect(claude.live().launch.options).not.toHaveProperty('resumeSessionAt') const lastCompletedTurn = { - handle: { - provider: 'claude', - sessionId: PROVIDER_SESSION, - leafUuid: 'provider-opened-assistant' - }, + handle: claudeProviderHandle(PROVIDER_SESSION, 'provider-opened-assistant'), origin: 'resumed' } expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject( diff --git a/src/main/runtime/orchestration/structured-session-mail-address.ts b/src/main/runtime/orchestration/structured-session-mail-address.ts index a5dffbeb48a4..839f80f2b81d 100644 --- a/src/main/runtime/orchestration/structured-session-mail-address.ts +++ b/src/main/runtime/orchestration/structured-session-mail-address.ts @@ -35,11 +35,7 @@ export function lookupOrcaAgentSession( } const owner = store .listRecords() - .find((candidate) => - candidate.providerHandleChain.some(({ handle }) => - handle.provider === 'claude' ? handle.sessionId === id : handle.threadId === id - ) - ) + .find((candidate) => candidate.providerHandleChain.some(({ handle }) => handle.nativeId === id)) return owner ? { kind: 'provider-id', orcaSessionId: owner.sessionId } : { kind: 'unknown' } } diff --git a/src/main/runtime/orchestration/structured-worker-journal-page.test.ts b/src/main/runtime/orchestration/structured-worker-journal-page.test.ts index d00d411a0675..b6e580fc77e3 100644 --- a/src/main/runtime/orchestration/structured-worker-journal-page.test.ts +++ b/src/main/runtime/orchestration/structured-worker-journal-page.test.ts @@ -17,6 +17,7 @@ import { readStructuredJournalPage, STRUCTURED_JOURNAL_PAGE_LIMIT } from './structured-worker-journal-page' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const host = vi.hoisted(() => { const state: { journal: AgentSessionJournal | null } = { journal: null } @@ -71,7 +72,7 @@ beforeEach(async () => { workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'claude-1', leafUuid: null } + providerHandle: claudeProviderHandle('claude-1', null) }, stateDirectory: root, now: () => ++clock, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 6d1ddf9f3594..8f7b60de517c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -14,6 +14,7 @@ import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../../../shared/agent-session-provider-handle-encoding' const SESSION = 'session-adoption-replay' const THREAD = 'thread-adoption-replay' @@ -42,7 +43,7 @@ function adapter(): StructuredAgentSessionAdapter { }, link: { linkId: `codex-${fence}-${THREAD}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'resumed', mintedAtFence: fence, observedAt: 1_800_000_000_000 diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 769bd32f17b3..224fc8c0f5ad 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -29,6 +29,7 @@ import { agentSessionFailureFact } from '../../../../shared/agent-session-failur import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../../../shared/agent-session-provider-handle-encoding' const CONNECTION = 'connection-1' const CLIENT = { @@ -69,7 +70,7 @@ beforeEach(async () => { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? ('resumed' as const) : ('created' as const), diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index c97672269eb8..834a49b8e562 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -39,6 +39,7 @@ import { commitStructuredAgentSessionCreate } from './structured-agent-session-c import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../../../shared/agent-session-provider-handle-encoding' const WORKTREE = `id:${HOST_TEST_LOCATION.workspaceId}` const SOURCE_TAB = `structured-agent-session-${HOST_TEST_SESSION}` @@ -74,10 +75,9 @@ function providerAdapter(): StructuredAgentSessionAdapter { mintedAtFence: input.fence, observedAt: HOST_TEST_NOW, origin: 'created' as const, - handle: { - provider: 'codex' as const, - threadId: `00000000-0000-4000-8000-${String(acquisitions).padStart(12, '0')}` - } + handle: codexProviderHandle( + `00000000-0000-4000-8000-${String(acquisitions).padStart(12, '0')}` + ) } } }), diff --git a/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts b/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts index 396b0b2bec77..e200260a7e28 100644 --- a/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts +++ b/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts @@ -14,6 +14,7 @@ import { structuredWorkerIdentities } from '../structured-worker-identity' import type { RpcRequest, RpcResponse } from './core' import { RpcDispatcher } from './dispatcher' import { ORCHESTRATION_METHODS } from './methods/orchestration' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' export const SESSION_X = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') export const SESSION_Y = testOrcaSessionId('7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64') @@ -43,11 +44,7 @@ export function sessionRecord( location: { ...base.location, workspaceId: WORKSPACE_X, ...overrides.location }, providerHandleChain: base.providerHandleChain.map((link) => ({ ...link, - handle: { - provider: 'claude' as const, - sessionId: overrides.providerId ?? `provider-${sessionId}`, - leafUuid: null - } + handle: claudeProviderHandle(overrides.providerId ?? `provider-${sessionId}`, null) })) } } diff --git a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts index 25379632875b..ff82a61406e7 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -21,6 +21,7 @@ import { indexedStatusFeedSession } from '../native-chat/agent-session-wire/stru import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ @@ -67,7 +68,7 @@ async function openJournal(): Promise { workspaceId: WORKSPACE_ID, hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, SESSION) }) diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts index 5afd2d2e7701..5f6020e8d0f8 100644 --- a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -12,6 +12,7 @@ import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' import { AgentHookServer, _internals } from '../agent-hooks/server' import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ @@ -66,7 +67,7 @@ async function awaitingApproval() { workspaceId: WORKTREE_ID, hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, SESSION) }) diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index b4d0399d10da..9678c137a799 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -21,6 +21,7 @@ import { TURN, type StructuredCodexRpcHarness } from './structured-codex-session-rpc-test-harness' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const journals = createTrackedJournalOpener() const WORKSPACE = 'workspace-1' @@ -118,7 +119,7 @@ describe('a structured codex session over agentSession.*', () => { workspaceId: WORKSPACE, hostId: 'local', agent: 'codex' as const, - providerHandle: { kind: 'codex' as const, threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) } const reopened = await journals.open({ identity, diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 3d9e72f74046..01d51311d65e 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -44,6 +44,7 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const journals = createTrackedJournalOpener() @@ -406,7 +407,7 @@ describe('a structured codex session over agentSession.*', () => { workspaceId: WORKSPACE, hostId: 'local', agent: 'codex' as const, - providerHandle: { kind: 'codex' as const, threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) } const journal = await journals.open({ identity, @@ -827,7 +828,7 @@ describe('a structured codex session over agentSession.*', () => { workspaceId: WORKSPACE, hostId: 'local', agent: 'codex' as const, - providerHandle: { kind: 'codex' as const, threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) } const reopened = await journals.open({ identity, diff --git a/src/main/runtime/structured-agent-session-record-import-install.test.ts b/src/main/runtime/structured-agent-session-record-import-install.test.ts index dd76d01e6c64..5c2256ad8827 100644 --- a/src/main/runtime/structured-agent-session-record-import-install.test.ts +++ b/src/main/runtime/structured-agent-session-record-import-install.test.ts @@ -18,6 +18,7 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { storedTestAgentSessionRecord } from './agent-session-record-store-test-harness' const NOW = 1_800_000_000_000 const IMPORTED = 'session-alpha-1' @@ -45,7 +46,7 @@ const legacyFile = (): string => JSON.stringify({ schemaVersion: 2, hostId: 'local', - records: { [IMPORTED]: agentSessionRecordFixture() }, + records: { [IMPORTED]: storedTestAgentSessionRecord(agentSessionRecordFixture()) }, operations: {}, retiredClaimKeys: [], unusableRecords: {} diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index 2727b2389f2f..706b4de5bce5 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -18,6 +18,7 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' describe('structured session runtime provider-exit wiring', () => { let root: string | null = null @@ -231,10 +232,9 @@ describe('structured session runtime provider-exit wiring', () => { await restarted.restoreReadableSessions() const history = await restarted.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok && history.page.items.some((item) => item.body.kind === 'status')).toBe(false) - expect(restarted.deps.store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toEqual({ - provider: 'codex', - threadId: 'thread-runtime-close' - }) + expect(restarted.deps.store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toEqual( + codexProviderHandle('thread-runtime-close') + ) }) it('waits for a start a send began before tearing down the runtime', async () => { diff --git a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts index aee47ab15bae..16cb94a812ff 100644 --- a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts +++ b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts @@ -18,6 +18,7 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' /** pid -> the NUL-separated environment block `/proc//environ` serves. */ const fakeProc = vi.hoisted(() => ({ environs: new Map() })) @@ -113,7 +114,7 @@ describe('a process that inherited a spawn token', () => { fence, link: { linkId: 'link-1', - handle: { provider: 'codex', threadId: 'thread-1' }, + handle: codexProviderHandle('thread-1'), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index ac6df4c4b2b3..70be04a9de72 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -27,7 +27,8 @@ import { } from './agent-session-record-store-file' import { readPersistedTestAgentSessionStore, - seedTestAgentSessionStoreFromNewerBuild + seedTestAgentSessionStoreFromNewerBuild, + storedTestAgentSessionRecord } from './agent-session-record-store-test-harness' import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' import { OrcaRuntimeService } from './orca-runtime' @@ -37,6 +38,7 @@ import { } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' // `failing` fails every record write; `grants` lets that many more through, then fails. const writes = vi.hoisted(() => ({ failing: false, grants: Infinity, refused: 0 })) @@ -94,7 +96,7 @@ function chatRecord( provider: 'codex' as const, providerHandleChain: record.providerHandleChain.map((link) => ({ ...link, - handle: { provider: 'codex' as const, threadId: `thread-${sessionId}` } + handle: codexProviderHandle(`thread-${sessionId}`) })), accountHome: { variable: 'CODEX_HOME' as const, path: join(root, 'codex-home') } } @@ -128,7 +130,9 @@ async function seedProfile( JSON.stringify({ schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, hostId: 'local', - records: Object.fromEntries(records.map((record) => [record.sessionId, record])), + records: Object.fromEntries( + records.map((record) => [record.sessionId, storedTestAgentSessionRecord(record)]) + ), operations: {}, retiredClaimKeys: [], unusableRecords: {}, @@ -182,7 +186,7 @@ async function seedChatOpenedWhileOwed(record: AgentSessionRecord, tabId: string }) database.db .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') - .run(record.sessionId, JSON.stringify(record)) + .run(record.sessionId, JSON.stringify(storedTestAgentSessionRecord(record))) await AgentSessionRecordStore.open({ journalDatabase: database, hostId: 'local' diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 49c694b5adbd..337738e3e9d8 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -105,8 +105,7 @@ function connectionFor(sessionId: string): FakeConnection { const head = agentSessionProviderHandleChainHead( host.deps.store.getRecord(sessionId)?.providerHandleChain ?? [] ) - const thread = - threadBySession.get(sessionId) ?? (head?.handle.provider === 'codex' && head.handle.threadId) + const thread = threadBySession.get(sessionId) ?? head?.handle.nativeId const connection = codex.connections.findLast((candidate) => candidate.threadId === thread) if (!connection) { throw new Error(`no app-server for ${sessionId}`) diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 9024c65b503d..c72d23ecf73e 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -1,5 +1,6 @@ import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import { resolveClaudeCommand } from '../codex-cli/command' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' import { createClaudeStructuredLaunchResolver } from '../claude/claude-structured-launch-resolution' @@ -12,7 +13,7 @@ import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent- import type { ClaudeStructuredSessionEvent } from '../claude/claude-structured-session-state' import { recordAgentSessionProviderHandle, - reviseAgentSessionClaudeResumePoint + reviseAgentSessionProviderResumePoint } from './agent-session-provider-handle-transition' import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import type { AgentSessionRecordStore } from './agent-session-record-store' @@ -113,11 +114,10 @@ export function createStructuredClaudeRuntimeAdapter( }, persistResumePoint: async ({ sessionId, providerSessionId, leafUuid, fence }) => { await store.transitionHandoff(sessionId, (record: AgentSessionRecord) => - reviseAgentSessionClaudeResumePoint({ + reviseAgentSessionProviderResumePoint({ record, fence, - providerSessionId, - leafUuid, + handle: claudeProviderHandle(providerSessionId, leafUuid), now: Date.now() }) ) diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index e5f8125a5c0f..f6adbae1f1d2 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -15,6 +15,7 @@ import type { AgentSessionQuestionAnswer } from './agent-session-question-answer import type { AgentJournalTurnOutcome } from './agent-turn-outcome' import type { NativeChatToolMetadata } from './native-chat-tool-identity' import type { AgentSessionContextUsage } from './agent-session-context-usage' +import type { AgentSessionProviderHandle } from './agent-session-provider-handle' import type { NativeChatBlock, NativeChatRole } from './native-chat-types' export { type AgentType } @@ -39,10 +40,11 @@ export type AgentJournalCursor = { sequence: number } -/** The durable provider session a journal is bound to. - * Codex is one thread id; Claude needs the leaf because concurrent resumes of - * one session id branch the same transcript. */ -export type AgentSessionProviderHandle = +/** A provider handle as journal rows record it, and (without `opaque`) as + * `agentSession.attach` carries it. Persisted and on the wire: never reshape an + * arm. Derived from the in-memory handle by `agentSessionJournalProviderHandle`; + * `opaque` names any other transport, or `pending` before a handle is proved. */ +export type AgentSessionJournalProviderHandle = | { kind: 'codex'; threadId: string } | { kind: 'claude'; sessionId: string; leafUuid: string | null } | { kind: 'opaque'; agent: AgentType; value: string } @@ -58,7 +60,8 @@ export type AgentSessionJournalIdentity = { /** Execution host that owns the process, so a client restart adjudicates nothing. */ hostId: string agent: AgentType - providerHandle: AgentSessionProviderHandle + /** The record's proved handle; null before the provider has proved one. */ + providerHandle: AgentSessionProviderHandle | null } // ─── Item identity ────────────────────────────────────────────────────────── diff --git a/src/shared/agent-session-legacy-handoff-lease.test.ts b/src/shared/agent-session-legacy-handoff-lease.test.ts index 49a10558b818..5bd90fbc71ea 100644 --- a/src/shared/agent-session-legacy-handoff-lease.test.ts +++ b/src/shared/agent-session-legacy-handoff-lease.test.ts @@ -2,15 +2,11 @@ import { describe, expect, it } from 'vitest' import { leaseCarriesLegacyHandoffValues, normalizeLegacyHandoffLease, - normalizeLegacyHandoffRecord, terminalOwnerRefusalMessage, type PersistedAgentSessionLease } from './agent-session-legacy-handoff-lease' import type { AgentSessionClaimStatus, AgentSessionHandoffStage } from './agent-session-record' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from './agent-session-record.test-fixture' +import { agentSessionLeaseFixture } from './agent-session-record.test-fixture' const CLAIMS: AgentSessionClaimStatus[] = ['reserved', 'live', 'conflicted', 'released'] const STAGES: (AgentSessionHandoffStage | null)[] = [null, 'new-owner-proving', 'recovering'] @@ -70,16 +66,6 @@ describe('normalizing a lease the removed terminal handoff wrote', () => { claimStatus: 'conflicted' }) }) - - it('reports whether a record needed normalizing', () => { - const record = agentSessionRecordFixture() - expect(normalizeLegacyHandoffRecord(record)).toEqual({ record, normalized: false }) - const legacy = { ...record, lease: persisted({ runtimeKind: 'tui' }) } - expect(normalizeLegacyHandoffRecord(legacy)).toEqual({ - record: { ...record, lease: { ...record.lease, claimStatus: 'conflicted' } }, - normalized: true - }) - }) }) describe('the refusal for a chat a terminal agent holds', () => { diff --git a/src/shared/agent-session-legacy-handoff-lease.ts b/src/shared/agent-session-legacy-handoff-lease.ts index ea4ef2909e8c..ce06ddce0eb7 100644 --- a/src/shared/agent-session-legacy-handoff-lease.ts +++ b/src/shared/agent-session-legacy-handoff-lease.ts @@ -13,6 +13,7 @@ import type { AgentSessionOwnerRuntimeKind, AgentSessionRecord } from './agent-session-record' +import type { PersistedAgentSessionProviderHandleLink } from './agent-session-provider-handle' type LegacyHandoffRuntimeKind = 'tui' type LegacyHandoffStage = 'preparing' | 'old-owner-stopped' | 'manual-recovery' @@ -28,8 +29,13 @@ export type PersistedAgentSessionLease = Omit & { +/** A record as a row stores it. Decode through `decodePersistedAgentSessionRecord`. */ +export type PersistedAgentSessionRecord = Omit< + AgentSessionRecord, + 'lease' | 'providerHandleChain' +> & { lease: PersistedAgentSessionLease + providerHandleChain: PersistedAgentSessionProviderHandleLink[] } export function isPersistedAgentSessionRuntimeKind( @@ -85,14 +91,3 @@ export function terminalOwnerRefusalMessage(lease: AgentSessionLease): string { const process = owner?.processStartTimeMs != null ? ` (process ${owner.pid})` : '' return `This chat is still open in a terminal agent${process}. Quit that agent to continue the chat here.` } - -/** The in-memory record, plus whether decode changed anything the store must write back. */ -export function normalizeLegacyHandoffRecord(record: PersistedAgentSessionRecord): { - record: AgentSessionRecord - normalized: boolean -} { - return { - record: { ...record, lease: normalizeLegacyHandoffLease(record.lease) }, - normalized: leaseCarriesLegacyHandoffValues(record.lease) - } -} diff --git a/src/shared/agent-session-provider-handle-encoding.ts b/src/shared/agent-session-provider-handle-encoding.ts new file mode 100644 index 000000000000..e15cc5339d86 --- /dev/null +++ b/src/shared/agent-session-provider-handle-encoding.ts @@ -0,0 +1,285 @@ +/** + * How a provider handle is stored and sent, and the only module that knows the two typed shapes + * the first structured transports were stored in. + * + * Claude and Codex handles are persisted and sent exactly as before this module existed, so an + * older build sharing the database or the wire still reads them. Every other transport is stored + * in the neutral shape, which an older build refuses as unreadable rather than mistaking for one + * of its own. Decoding is strict: each handle has exactly one stored form, and the key and root + * strings derived from it are persisted elsewhere, so none of them may ever change. + */ + +import type { + AgentSessionHandleProvider, + AgentSessionProviderHandle, + AgentSessionProviderHandleNamespace +} from './agent-session-provider-handle' +import type { AgentSessionJournalProviderHandle, AgentType } from './agent-session-journal-types' + +export const CLAUDE_STRUCTURED_HANDLE_NAMESPACE = { + transport: 'claude-sdk', + agent: 'claude' +} as const satisfies AgentSessionProviderHandleNamespace + +export const CODEX_STRUCTURED_HANDLE_NAMESPACE = { + transport: 'codex-app-server', + agent: 'codex' +} as const satisfies AgentSessionProviderHandleNamespace + +/** The namespace a record's handles live in, from the structured lane it was created on. */ +export function agentSessionProviderHandleNamespace( + provider: AgentSessionHandleProvider +): AgentSessionProviderHandleNamespace { + return provider === 'claude' + ? CLAUDE_STRUCTURED_HANDLE_NAMESPACE + : CODEX_STRUCTURED_HANDLE_NAMESPACE +} + +export function isAgentSessionProviderHandleInNamespace( + handle: AgentSessionProviderHandleNamespace, + namespace: AgentSessionProviderHandleNamespace +): boolean { + return handle.transport === namespace.transport && handle.agent === namespace.agent +} + +/** Whether a handle belongs to a record of this lane. Compares namespaces; never reads its data. */ +export function agentSessionProviderHandleBelongsTo( + handle: AgentSessionProviderHandle, + provider: AgentSessionHandleProvider +): boolean { + return isAgentSessionProviderHandleInNamespace( + handle, + agentSessionProviderHandleNamespace(provider) + ) +} + +/** Claude's provider data is its resume leaf: the transcript entry a resume continues from. */ +export function claudeProviderHandle( + sessionId: string, + leafUuid: string | null +): AgentSessionProviderHandle { + return { + ...CLAUDE_STRUCTURED_HANDLE_NAMESPACE, + nativeId: sessionId, + ...(leafUuid === null ? {} : { providerData: leafUuid }) + } +} + +export function claudeProviderHandleLeafUuid(handle: AgentSessionProviderHandle): string | null { + return handle.providerData ?? null +} + +export function codexProviderHandle(threadId: string): AgentSessionProviderHandle { + return { ...CODEX_STRUCTURED_HANDLE_NAMESPACE, nativeId: threadId } +} + +// ─── Validation ───────────────────────────────────────────────────────────── + +const MAX_HANDLE_FIELD_LENGTH = 512 +/** A generic handle's key carries escaped ids, so it may outgrow a legacy key's bound. */ +const MAX_NEUTRAL_HANDLE_KEY_LENGTH = 8192 +const MAX_PROVIDER_DATA_LENGTH = 4096 +const NAMESPACE_PART_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/ + +export function isAgentSessionProviderHandleField(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_HANDLE_FIELD_LENGTH && + value === value.trim() + ) +} + +function isNamespacePart(value: unknown): value is string { + return typeof value === 'string' && NAMESPACE_PART_PATTERN.test(value) +} + +function isLegacyNamespace(handle: AgentSessionProviderHandleNamespace): boolean { + return ( + isAgentSessionProviderHandleInNamespace(handle, CLAUDE_STRUCTURED_HANDLE_NAMESPACE) || + isAgentSessionProviderHandleInNamespace(handle, CODEX_STRUCTURED_HANDLE_NAMESPACE) + ) +} + +/** + * An in-memory handle. A Claude or Codex handle must also fit its typed stored shape: Claude's + * provider data is a leaf id, and Codex has none. + */ +export function isAgentSessionProviderHandle(value: unknown): value is AgentSessionProviderHandle { + if (typeof value !== 'object' || value === null || Object.hasOwn(value, 'provider')) { + return false + } + const transport = 'transport' in value ? value.transport : undefined + const agent = 'agent' in value ? value.agent : undefined + const nativeId = 'nativeId' in value ? value.nativeId : undefined + const providerData = 'providerData' in value ? value.providerData : undefined + if ( + !isNamespacePart(transport) || + !isNamespacePart(agent) || + !isAgentSessionProviderHandleField(nativeId) + ) { + return false + } + const namespace = { transport, agent } + if (isAgentSessionProviderHandleInNamespace(namespace, CODEX_STRUCTURED_HANDLE_NAMESPACE)) { + return providerData === undefined + } + if (isAgentSessionProviderHandleInNamespace(namespace, CLAUDE_STRUCTURED_HANDLE_NAMESPACE)) { + return providerData === undefined || isAgentSessionProviderHandleField(providerData) + } + return ( + providerData === undefined || + (typeof providerData === 'string' && + providerData.length > 0 && + providerData.length <= MAX_PROVIDER_DATA_LENGTH) + ) +} + +/** Bound for a persisted key that names a handle in this namespace (`forkedFromKey`, `supersedesKey`). */ +export function isAgentSessionProviderHandleKeyFor( + handle: AgentSessionProviderHandle, + value: unknown +): value is string { + return isLegacyNamespace(handle) + ? isAgentSessionProviderHandleField(value) + : typeof value === 'string' && value.length > 0 && value.length <= MAX_NEUTRAL_HANDLE_KEY_LENGTH +} + +// ─── Stored form ──────────────────────────────────────────────────────────── + +type StoredClaudeProviderHandle = { provider: 'claude'; sessionId: string; leafUuid: string | null } +type StoredCodexProviderHandle = { provider: 'codex'; threadId: string } + +/** A handle as a record row stores it. */ +export type PersistedAgentSessionProviderHandle = + | StoredClaudeProviderHandle + | StoredCodexProviderHandle + | AgentSessionProviderHandle + +export function encodePersistedAgentSessionProviderHandle( + handle: AgentSessionProviderHandle +): PersistedAgentSessionProviderHandle { + if (isAgentSessionProviderHandleInNamespace(handle, CLAUDE_STRUCTURED_HANDLE_NAMESPACE)) { + return { provider: 'claude', sessionId: handle.nativeId, leafUuid: handle.providerData ?? null } + } + if (isAgentSessionProviderHandleInNamespace(handle, CODEX_STRUCTURED_HANDLE_NAMESPACE)) { + return { provider: 'codex', threadId: handle.nativeId } + } + return { + transport: handle.transport, + agent: handle.agent, + nativeId: handle.nativeId, + ...(handle.providerData === undefined ? {} : { providerData: handle.providerData }) + } +} + +export function decodePersistedAgentSessionProviderHandle( + value: unknown +): AgentSessionProviderHandle | null { + if (typeof value !== 'object' || value === null) { + return null + } + const provider = 'provider' in value ? value.provider : undefined + if (provider === 'claude') { + const sessionId = 'sessionId' in value ? value.sessionId : undefined + // Why: a null leaf and a missing one were never the same stored value; keep refusing the second. + const leafUuid = 'leafUuid' in value ? value.leafUuid : undefined + return isAgentSessionProviderHandleField(sessionId) && + (leafUuid === null || isAgentSessionProviderHandleField(leafUuid)) + ? claudeProviderHandle(sessionId, leafUuid) + : null + } + if (provider === 'codex') { + const threadId = 'threadId' in value ? value.threadId : undefined + return isAgentSessionProviderHandleField(threadId) ? codexProviderHandle(threadId) : null + } + // A Claude or Codex handle has exactly one stored form, the typed one. + if (!isAgentSessionProviderHandle(value) || isLegacyNamespace(value)) { + return null + } + return { + transport: value.transport, + agent: value.agent, + nativeId: value.nativeId, + ...(value.providerData === undefined ? {} : { providerData: value.providerData }) + } +} + +// ─── Identity strings (persisted: never change them) ──────────────────────── + +/** Stable string identity for one handle. Two handles with the same key name the same writer target. */ +export function agentSessionProviderHandleKey(handle: AgentSessionProviderHandle): string { + const stored = encodePersistedAgentSessionProviderHandle(handle) + if ('provider' in stored) { + return stored.provider === 'claude' + ? `claude:${JSON.stringify([stored.sessionId, stored.leafUuid])}` + : `codex:${JSON.stringify(stored.threadId)}` + } + // Why: provider data is resume state the adapter owns, not identity; only Claude's leaf, a + // branch cursor, was ever part of a key, and its key above is fixed by records already written. + return agentSessionProviderHandleRoot(handle) +} + +/** + * Identity root: the part that a resume must preserve. A resume that changes the root is a fork, + * whatever the provider called it. + */ +export function agentSessionProviderHandleRoot(handle: AgentSessionProviderHandle): string { + const stored = encodePersistedAgentSessionProviderHandle(handle) + if ('provider' in stored) { + return stored.provider === 'claude' + ? `claude:${JSON.stringify(stored.sessionId)}` + : `codex:${JSON.stringify(stored.threadId)}` + } + // Namespace parts cannot hold `/` or `:`, so this never reads as a typed root. + return `${handle.transport}/${handle.agent}:${JSON.stringify(handle.nativeId)}` +} + +// ─── Wire and journal forms ───────────────────────────────────────────────── + +/** A handle as `agentSession.attach` carries it and an attach fingerprint covers it. */ +export type AgentSessionWireProviderHandle = Exclude< + AgentSessionJournalProviderHandle, + { kind: 'opaque' } +> + +export function agentSessionProviderHandleFromWire( + wire: AgentSessionWireProviderHandle +): AgentSessionProviderHandle { + return wire.kind === 'claude' + ? claudeProviderHandle(wire.sessionId, wire.leafUuid) + : codexProviderHandle(wire.threadId) +} + +/** Null for a transport the attach wire cannot carry. */ +export function agentSessionWireProviderHandle( + handle: AgentSessionProviderHandle +): AgentSessionWireProviderHandle | null { + const stored = encodePersistedAgentSessionProviderHandle(handle) + if (!('provider' in stored)) { + return null + } + return stored.provider === 'claude' + ? { kind: 'claude', sessionId: stored.sessionId, leafUuid: stored.leafUuid } + : { kind: 'codex', threadId: stored.threadId } +} + +/** What a journal row records before the provider has proved any handle. */ +export const AGENT_SESSION_PENDING_PROVIDER_HANDLE_VALUE = 'pending' + +/** The handle a journal row records. Write-only: rows keep it for diagnosis, nothing reads it. */ +export function agentSessionJournalProviderHandle( + handle: AgentSessionProviderHandle | null, + agent: AgentType +): AgentSessionJournalProviderHandle { + if (!handle) { + return { kind: 'opaque', agent, value: AGENT_SESSION_PENDING_PROVIDER_HANDLE_VALUE } + } + return ( + agentSessionWireProviderHandle(handle) ?? { + kind: 'opaque', + agent: handle.agent, + value: handle.nativeId + } + ) +} diff --git a/src/shared/agent-session-provider-handle.test.ts b/src/shared/agent-session-provider-handle.test.ts index 7474c751cbe0..463eff97ade7 100644 --- a/src/shared/agent-session-provider-handle.test.ts +++ b/src/shared/agent-session-provider-handle.test.ts @@ -13,12 +13,9 @@ import { type AgentSessionProviderHandle, type AgentSessionProviderHandleLink } from './agent-session-provider-handle' +import { claudeProviderHandle, codexProviderHandle } from './agent-session-provider-handle-encoding' -const CLAUDE: AgentSessionProviderHandle = { - provider: 'claude', - sessionId: 'sess-1', - leafUuid: 'leaf-1' -} +const CLAUDE: AgentSessionProviderHandle = claudeProviderHandle('sess-1', 'leaf-1') function link(overrides: Partial = {}) { return { @@ -42,41 +39,35 @@ describe('handle identity', () => { it('keys a Claude handle by session id AND leaf, so two branches are two handles', () => { // Concurrent resumes branch one transcript silently; the session id alone cannot name a writer. const branchA = agentSessionProviderHandleKey(CLAUDE) - const branchB = agentSessionProviderHandleKey({ ...CLAUDE, leafUuid: 'leaf-2' }) + const branchB = agentSessionProviderHandleKey(claudeProviderHandle('sess-1', 'leaf-2')) expect(branchA).not.toEqual(branchB) expect(agentSessionProviderHandleRoot(CLAUDE)).toEqual( - agentSessionProviderHandleRoot({ ...CLAUDE, leafUuid: 'leaf-2' }) + agentSessionProviderHandleRoot(claudeProviderHandle('sess-1', 'leaf-2')) ) }) it('keys a Codex handle by thread id alone', () => { - const codex: AgentSessionProviderHandle = { provider: 'codex', threadId: 'thread-1' } + const codex: AgentSessionProviderHandle = codexProviderHandle('thread-1') expect(agentSessionProviderHandleKey(codex)).toBe('codex:"thread-1"') expect(agentSessionProviderHandleRoot(codex)).toBe('codex:"thread-1"') - expect( - agentSessionProviderHandlesEqual(codex, { provider: 'codex', threadId: 'thread-2' }) - ).toBe(false) + expect(agentSessionProviderHandlesEqual(codex, codexProviderHandle('thread-2'))).toBe(false) }) it('distinguishes a null leaf from an empty-string leaf and rejects malformed handles', () => { - expect(isAgentSessionProviderHandle({ ...CLAUDE, leafUuid: null })).toBe(true) - expect(isAgentSessionProviderHandle({ ...CLAUDE, leafUuid: '' })).toBe(false) - expect(isAgentSessionProviderHandle({ provider: 'claude', sessionId: '' })).toBe(false) + expect(isAgentSessionProviderHandle(claudeProviderHandle('sess-1', null))).toBe(true) + expect(isAgentSessionProviderHandle({ ...CLAUDE, providerData: '' })).toBe(false) + expect(isAgentSessionProviderHandle({ ...CLAUDE, nativeId: '' })).toBe(false) + expect(isAgentSessionProviderHandle({ ...CLAUDE, nativeId: ' sess-1 ' })).toBe(false) + // A stored typed shape is not an in-memory handle: it must be decoded first. + expect( + isAgentSessionProviderHandle({ provider: 'claude', sessionId: 'x', leafUuid: null }) + ).toBe(false) expect(isAgentSessionProviderHandle({ provider: 'gemini', sessionId: 'x' })).toBe(false) - expect(isAgentSessionProviderHandle({ ...CLAUDE, sessionId: ' sess-1 ' })).toBe(false) }) it('uses collision-free keys when Claude ids contain delimiters', () => { - const left: AgentSessionProviderHandle = { - provider: 'claude', - sessionId: 'a#b', - leafUuid: 'c' - } - const right: AgentSessionProviderHandle = { - provider: 'claude', - sessionId: 'a', - leafUuid: 'b#c' - } + const left = claudeProviderHandle('a#b', 'c') + const right = claudeProviderHandle('a', 'b#c') expect(agentSessionProviderHandleKey(left)).not.toBe(agentSessionProviderHandleKey(right)) expect(agentSessionProviderHandlesEqual(left, right)).toBe(false) }) @@ -101,7 +92,7 @@ describe('chain append', () => { link({ linkId: 'link-2', origin: 'resumed', - handle: { provider: 'claude', sessionId: 'sess-2', leafUuid: 'leaf-9' }, + handle: claudeProviderHandle('sess-2', 'leaf-9'), mintedAtFence: 2 }) ) @@ -113,7 +104,7 @@ describe('chain append', () => { const forked = link({ linkId: 'link-2', origin: 'forked', - handle: { provider: 'claude', sessionId: 'sess-2', leafUuid: 'leaf-9' }, + handle: claudeProviderHandle('sess-2', 'leaf-9'), mintedAtFence: 2, forkedFromKey: agentSessionProviderHandleKey(CLAUDE) }) @@ -138,7 +129,7 @@ describe('chain append', () => { link({ linkId: 'link-2', origin: 'resumed', - handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + handle: claudeProviderHandle('sess-1', 'leaf-2'), mintedAtFence: 4 }) ) @@ -152,7 +143,7 @@ describe('chain append', () => { link({ linkId: 'link-2', origin: 'resumed', - handle: { provider: 'codex', threadId: 'thread-1' }, + handle: codexProviderHandle('thread-1'), mintedAtFence: 2 }) ) @@ -182,7 +173,7 @@ describe('chain append', () => { [link()], link({ origin: 'resumed', - handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + handle: claudeProviderHandle('sess-1', 'leaf-2'), mintedAtFence: 2 }) ) @@ -196,7 +187,7 @@ describe('chain append', () => { link({ linkId: `link-${index + 1}`, origin: 'resumed', - handle: { ...CLAUDE, leafUuid: `leaf-${index + 1}` }, + handle: claudeProviderHandle('sess-1', `leaf-${index + 1}`), mintedAtFence: index + 1 }) ) @@ -208,7 +199,7 @@ describe('chain append', () => { link({ linkId: 'link-overflow', origin: 'resumed', - handle: { ...CLAUDE, leafUuid: 'leaf-overflow' }, + handle: claudeProviderHandle('sess-1', 'leaf-overflow'), mintedAtFence: 999 }) ) @@ -226,7 +217,7 @@ describe('chain append', () => { link({ linkId: 'link-2', origin: 'resumed', - handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + handle: claudeProviderHandle('sess-1', 'leaf-2'), mintedAtFence: 2 }) ) @@ -241,7 +232,7 @@ describe('chain lookup and validation', () => { link({ linkId: 'link-2', origin: 'resumed', - handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + handle: claudeProviderHandle('sess-1', 'leaf-2'), mintedAtFence: 2 }) ) @@ -282,7 +273,7 @@ describe('chain lookup and validation', () => { link(), link({ origin: 'resumed', - handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + handle: claudeProviderHandle('sess-1', 'leaf-2'), mintedAtFence: 2 }) ]) @@ -293,7 +284,7 @@ describe('chain lookup and validation', () => { link({ linkId: 'link-2', origin: 'resumed', - handle: { provider: 'claude', sessionId: 'sess-2', leafUuid: 'leaf-2' }, + handle: claudeProviderHandle('sess-2', 'leaf-2'), mintedAtFence: 2 }) ]) @@ -308,7 +299,7 @@ describe('adopted chain heads', () => { link({ linkId: 'claude-1-sess-1-empty', origin: 'adopted', - handle: { ...CLAUDE, leafUuid: null }, + handle: claudeProviderHandle('sess-1', null), ...overrides }) @@ -333,7 +324,7 @@ describe('adopted chain heads', () => { link({ linkId: 'claude-1-sess-1-empty-retry', origin: 'resumed', - handle: { ...CLAUDE, leafUuid: null }, + handle: claudeProviderHandle('sess-1', null), mintedAtFence: 1 }) ) @@ -345,12 +336,12 @@ describe('adopted chain heads', () => { const codexAdopted = link({ linkId: 'codex-1-thread-1', origin: 'adopted', - handle: { provider: 'codex', threadId: 'thread-1' } + handle: codexProviderHandle('thread-1') }) const reproved = link({ linkId: 'codex-1-thread-1-retry', origin: 'resumed', - handle: { provider: 'codex', threadId: 'thread-1' }, + handle: codexProviderHandle('thread-1'), mintedAtFence: 1 }) @@ -380,7 +371,7 @@ describe('adopted chain heads', () => { link({ linkId: 'claude-1-sess-9-leaf-9', origin: 'resumed', - handle: { provider: 'claude', sessionId: 'sess-9', leafUuid: 'leaf-9' }, + handle: claudeProviderHandle('sess-9', 'leaf-9'), mintedAtFence: 1 }) ) @@ -393,12 +384,12 @@ describe('adopted chain heads', () => { }) describe('superseding a creation the provider never saved', () => { - const unsaved: AgentSessionProviderHandle = { provider: 'codex', threadId: 'thread-unsaved' } + const unsaved: AgentSessionProviderHandle = codexProviderHandle('thread-unsaved') const created = link({ linkId: 'codex-1-thread-unsaved', handle: unsaved }) function replacement(overrides: Partial = {}) { return link({ linkId: 'codex-3-thread-new', - handle: { provider: 'codex', threadId: 'thread-new' }, + handle: codexProviderHandle('thread-new'), mintedAtFence: 3, supersedesKey: agentSessionProviderHandleKey(unsaved), ...overrides @@ -414,13 +405,13 @@ describe('superseding a creation the provider never saved', () => { chain, replacement({ linkId: 'codex-5-thread-newer', - handle: { provider: 'codex', threadId: 'thread-newer' }, + handle: codexProviderHandle('thread-newer'), mintedAtFence: 5, - supersedesKey: agentSessionProviderHandleKey({ provider: 'codex', threadId: 'thread-new' }) + supersedesKey: agentSessionProviderHandleKey(codexProviderHandle('thread-new')) }) ) expect(again).toHaveLength(1) - expect(again[0]?.handle).toEqual({ provider: 'codex', threadId: 'thread-newer' }) + expect(again[0]?.handle).toEqual(codexProviderHandle('thread-newer')) }) it('never supersedes a conversation a resume, fork or adoption proved', () => { @@ -467,3 +458,64 @@ describe('superseding a creation the provider never saved', () => { expect(isAgentSessionProviderHandleChain([created, replacement()])).toBe(false) }) }) + +describe('a transport shared code has never heard of', () => { + const acp = (nativeId: string, providerData?: string): AgentSessionProviderHandle => ({ + transport: 'acp', + agent: 'grok', + nativeId, + ...(providerData === undefined ? {} : { providerData }) + }) + + it('chains resumes and forks by its native id alone', () => { + const created = link({ handle: acp('s-1', '{"cwd":"/a"}') }) + const resumed = link({ + linkId: 'link-2', + origin: 'resumed', + handle: acp('s-1', '{"cwd":"/b"}'), + mintedAtFence: 2 + }) + const chain = appendAgentSessionProviderHandleLink([created], resumed) + expect(chain).toHaveLength(2) + expect(isAgentSessionProviderHandleChain(chain)).toBe(true) + expect(() => + appendAgentSessionProviderHandleLink(chain, { + ...resumed, + linkId: 'link-3', + handle: acp('s-2') + }) + ).toThrow('agent_session_provider_handle_forked') + const forked = appendAgentSessionProviderHandleLink(chain, { + ...resumed, + linkId: 'link-3', + origin: 'forked', + handle: acp('s-2'), + forkedFromKey: agentSessionProviderHandleKey(resumed.handle) + }) + expect(forked.at(-1)?.handle.nativeId).toBe('s-2') + }) + + it('records a same-fence resume that only moved provider data instead of eliding it', () => { + const created = link({ handle: acp('s-1', 'a') }) + const moved = link({ linkId: 'link-2', origin: 'resumed', handle: acp('s-1', 'b') }) + expect(appendAgentSessionProviderHandleLink([created], moved)).toHaveLength(2) + expect( + appendAgentSessionProviderHandleLink([created], { ...moved, handle: acp('s-1', 'a') }) + ).toHaveLength(1) + }) + + it('never joins a chain of another transport or agent, even with the same id', () => { + const chain = [link({ handle: codexProviderHandle('shared-id') })] + for (const handle of [ + acp('shared-id'), + { transport: 'codex-app-server', agent: 'grok', nativeId: 'shared-id' } + ]) { + expect(() => + appendAgentSessionProviderHandleLink( + chain, + link({ linkId: 'link-2', origin: 'resumed', handle, mintedAtFence: 2 }) + ) + ).toThrow() + } + }) +}) diff --git a/src/shared/agent-session-provider-handle.ts b/src/shared/agent-session-provider-handle.ts index ea789dcdfe40..c16c87e1490a 100644 --- a/src/shared/agent-session-provider-handle.ts +++ b/src/shared/agent-session-provider-handle.ts @@ -1,14 +1,36 @@ /** * Durable provider handle chain for an agent session. * - * Handles are keyed per provider because the two structured lanes disagree about what - * identifies a conversation. Claude's session id is the identity root and its leaf uuid is a - * branch cursor; Codex's thread id is the whole key. Resumes extend the chain, forks start a new - * identity root, and the chain records which is which so a fork is never presented as a resume. - * A creation the provider never saved can be superseded by a new creation, which takes its place - * instead of standing beside it: the unsaved handle was never a conversation to continue. + * A handle names one provider conversation without saying what the provider means by it. Shared + * code reads only its transport, its agent, and the provider's own conversation id; anything else + * the provider needs to resume is `providerData`, which only that provider's adapter reads. How a + * handle is stored and sent lives in agent-session-provider-handle-encoding.ts. + * + * Resumes extend the chain, forks start a new identity root, and the chain records which is which + * so a fork is never presented as a resume. A creation the provider never saved can be superseded + * by a new creation, which takes its place instead of standing beside it: the unsaved handle was + * never a conversation to continue. */ +import type { AgentType } from './agent-status-types' +import { + agentSessionProviderHandleKey, + agentSessionProviderHandleRoot, + decodePersistedAgentSessionProviderHandle, + encodePersistedAgentSessionProviderHandle, + isAgentSessionProviderHandle, + isAgentSessionProviderHandleInNamespace, + isAgentSessionProviderHandleKeyFor, + type PersistedAgentSessionProviderHandle +} from './agent-session-provider-handle-encoding' + +export { + agentSessionProviderHandleKey, + agentSessionProviderHandleRoot, + isAgentSessionProviderHandle +} from './agent-session-provider-handle-encoding' + +/** The structured lanes this build drives. A record names one; a handle never does. */ export const AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS = ['claude', 'codex'] as const export type AgentSessionHandleProvider = (typeof AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS)[number] @@ -18,9 +40,24 @@ export function isAgentSessionHandleProvider(value: unknown): value is AgentSess return value === 'claude' || value === 'codex' } -export type AgentSessionProviderHandle = - | { provider: 'claude'; sessionId: string; leafUuid: string | null } - | { provider: 'codex'; threadId: string } +/** The protocol whose id space a handle's `nativeId` lives in. Open: a new transport adds no arm. */ +export type AgentSessionProviderTransport = 'claude-sdk' | 'codex-app-server' | (string & {}) + +export type AgentSessionProviderHandle = { + transport: AgentSessionProviderTransport + /** Orca agent whose binary resumes the conversation; one transport serves many agents. */ + agent: AgentType + /** The provider's own conversation id: Claude's session id, Codex's thread id. */ + nativeId: string + /** Resume state only the provider's adapter reads, such as Claude's branch leaf. */ + providerData?: string +} + +/** Which provider a handle belongs to. Records and leases compare this, never the handle's data. */ +export type AgentSessionProviderHandleNamespace = Pick< + AgentSessionProviderHandle, + 'transport' | 'agent' +> export type AgentSessionProviderHandleOrigin = 'created' | 'adopted' | 'resumed' | 'forked' @@ -43,54 +80,19 @@ export type AgentSessionProviderHandleChain = readonly AgentSessionProviderHandl /** Bounded so one session cannot grow an unbounded persisted record. */ export const MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS = 256 -const MAX_HANDLE_FIELD_LENGTH = 512 const LINK_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ -function isHandleField(value: unknown): value is string { - return ( - typeof value === 'string' && - value.length > 0 && - value.length <= MAX_HANDLE_FIELD_LENGTH && - value === value.trim() - ) -} - -export function isAgentSessionProviderHandle(value: unknown): value is AgentSessionProviderHandle { - if (typeof value !== 'object' || value === null) { - return false - } - const handle = value as Partial & Record - if (handle.provider === 'claude') { - return ( - isHandleField(handle.sessionId) && - (handle.leafUuid === null || isHandleField(handle.leafUuid)) - ) - } - return handle.provider === 'codex' && isHandleField(handle.threadId) -} - -/** Stable string identity for one handle. Two handles with the same key name the same writer target. */ -export function agentSessionProviderHandleKey(handle: AgentSessionProviderHandle): string { - return handle.provider === 'claude' - ? `claude:${JSON.stringify([handle.sessionId, handle.leafUuid])}` - : `codex:${JSON.stringify(handle.threadId)}` -} - -/** - * Identity root: the part that a resume must preserve. A resume that changes the root is a fork, - * whatever the provider called it. - */ -export function agentSessionProviderHandleRoot(handle: AgentSessionProviderHandle): string { - return handle.provider === 'claude' - ? `claude:${JSON.stringify(handle.sessionId)}` - : `codex:${JSON.stringify(handle.threadId)}` -} - +/** Every field, provider data included: a resume that only moved the adapter's state is still news. */ export function agentSessionProviderHandlesEqual( left: AgentSessionProviderHandle, right: AgentSessionProviderHandle ): boolean { - return agentSessionProviderHandleKey(left) === agentSessionProviderHandleKey(right) + return ( + left.transport === right.transport && + left.agent === right.agent && + left.nativeId === right.nativeId && + left.providerData === right.providerData + ) } export function agentSessionProviderHandleChainHead( @@ -113,6 +115,10 @@ export function isAgentSessionProviderHandleLink( return false } const link = value as Partial + if (!isAgentSessionProviderHandle(link.handle)) { + return false + } + const handle = link.handle const originValid = link.origin === 'created' || link.origin === 'adopted' || @@ -121,16 +127,15 @@ export function isAgentSessionProviderHandleLink( return ( typeof link.linkId === 'string' && LINK_ID_PATTERN.test(link.linkId) && - isAgentSessionProviderHandle(link.handle) && originValid && Number.isSafeInteger(link.mintedAtFence) && (link.mintedAtFence as number) >= 0 && Number.isSafeInteger(link.observedAt) && (link.origin === 'forked' - ? isHandleField(link.forkedFromKey) + ? isAgentSessionProviderHandleKeyFor(handle, link.forkedFromKey) : link.forkedFromKey === undefined) && (link.supersedesKey === undefined || - (link.origin === 'created' && isHandleField(link.supersedesKey))) + (link.origin === 'created' && isAgentSessionProviderHandleKeyFor(handle, link.supersedesKey))) ) } @@ -177,7 +182,7 @@ export function appendAgentSessionProviderHandleLink( } return [link] } - if (link.handle.provider !== head.handle.provider) { + if (!isAgentSessionProviderHandleInNamespace(link.handle, head.handle)) { throw new Error('agent_session_provider_handle_provider_mismatch') } if (link.mintedAtFence < head.mintedAtFence) { @@ -245,3 +250,43 @@ function supersedeUnsavedCreation( // Why: in place, so a chat reopened unused across many restarts never grows toward the cap. return [link] } + +// ─── Stored form ──────────────────────────────────────────────────────────── + +/** A link as a record row stores it: only the handle has a stored form of its own. */ +export type PersistedAgentSessionProviderHandleLink = Omit< + AgentSessionProviderHandleLink, + 'handle' +> & { + handle: PersistedAgentSessionProviderHandle +} + +export function encodePersistedAgentSessionProviderHandleChain( + chain: AgentSessionProviderHandleChain +): PersistedAgentSessionProviderHandleLink[] { + return chain.map((link) => ({ + ...link, + handle: encodePersistedAgentSessionProviderHandle(link.handle) + })) +} + +/** The in-memory chain a stored one names, or null when any link or the chain itself is invalid. */ +export function decodePersistedAgentSessionProviderHandleChain( + value: unknown +): AgentSessionProviderHandleLink[] | null { + if (!Array.isArray(value) || value.length > MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { + return null + } + const decoded: unknown[] = [] + for (const link of value) { + const handle = + typeof link === 'object' && link !== null && 'handle' in link + ? decodePersistedAgentSessionProviderHandle(link.handle) + : null + if (!handle) { + return null + } + decoded.push({ ...link, handle }) + } + return isAgentSessionProviderHandleChain(decoded) ? decoded : null +} diff --git a/src/shared/agent-session-record-stored-form.test.ts b/src/shared/agent-session-record-stored-form.test.ts new file mode 100644 index 000000000000..5070ae67d47e --- /dev/null +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from 'vitest' +import { isAgentSessionHandleProvider } from './agent-session-provider-handle' +import { + agentSessionJournalProviderHandle, + agentSessionProviderHandleFromWire, + agentSessionProviderHandleKey, + agentSessionProviderHandleRoot, + agentSessionWireProviderHandle, + claudeProviderHandle, + codexProviderHandle, + decodePersistedAgentSessionProviderHandle, + encodePersistedAgentSessionProviderHandle +} from './agent-session-provider-handle-encoding' +import { isPersistedAgentSessionRecord } from './agent-session-record' +import { + decodePersistedAgentSessionRecord, + encodeAgentSessionRecord +} from './agent-session-record-stored-form' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from './agent-session-record.test-fixture' + +/** The rules of the handle guard builds before the neutral handle shipped ran on every stored one. */ +function olderBuildReadsHandle(value: unknown): boolean { + const isHandleField = (field: unknown): boolean => + typeof field === 'string' && field.length > 0 && field.length <= 512 && field === field.trim() + if (typeof value !== 'object' || value === null || !('provider' in value)) { + return false + } + if (value.provider === 'claude') { + return ( + 'sessionId' in value && + isHandleField(value.sessionId) && + 'leafUuid' in value && + (value.leafUuid === null || isHandleField(value.leafUuid)) + ) + } + return value.provider === 'codex' && 'threadId' in value && isHandleField(value.threadId) +} + +/** A Claude record row exactly as builds before this change wrote it: a fork with its seed key. */ +const STORED_CLAUDE_ROW = JSON.stringify({ + schemaVersion: 2, + sessionId: 'session-alpha-1', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'claude', + providerHandleChain: [ + { + linkId: 'link-1', + handle: { provider: 'claude', sessionId: 'sess-1', leafUuid: 'leaf-1' }, + origin: 'created', + mintedAtFence: 6, + observedAt: 1000 + }, + { + linkId: 'link-2', + handle: { provider: 'claude', sessionId: 'sess-2', leafUuid: null }, + origin: 'forked', + mintedAtFence: 7, + observedAt: 1500, + forkedFromKey: 'claude:["sess-1","leaf-1"]' + } + ], + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/user/.claude' }, + lease: agentSessionLeaseFixture({ provenHandleLinkId: 'link-2' }), + createdAt: 1000, + updatedAt: 2000 +}) + +/** A Codex row with a creation that replaced one Codex never saved. */ +const STORED_CODEX_ROW = JSON.stringify({ + ...JSON.parse(STORED_CLAUDE_ROW), + provider: 'codex', + providerHandleChain: [ + { + linkId: 'codex-7-thread-new', + handle: { provider: 'codex', threadId: 'thread-new' }, + origin: 'created', + mintedAtFence: 7, + observedAt: 1500, + supersedesKey: 'codex:"thread-unsaved"' + } + ], + accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' }, + lease: agentSessionLeaseFixture({ provenHandleLinkId: 'codex-7-thread-new' }) +}) + +describe('a record row older builds wrote', () => { + it.each([ + ['Claude', STORED_CLAUDE_ROW], + ['Codex', STORED_CODEX_ROW] + ])('reads a %s row and writes it back byte for byte', (_name, row) => { + const stored: unknown = JSON.parse(row) + expect(isPersistedAgentSessionRecord(stored)).toBe(true) + if (!isPersistedAgentSessionRecord(stored)) { + return + } + const { record, normalized } = decodePersistedAgentSessionRecord(stored) + expect(normalized).toBe(false) + expect(JSON.stringify(encodeAgentSessionRecord(record))).toBe(row) + }) + + it('hands shared code only the neutral handle', () => { + const stored: unknown = JSON.parse(STORED_CLAUDE_ROW) + if (!isPersistedAgentSessionRecord(stored)) { + throw new Error('fixture row must be readable') + } + const { record } = decodePersistedAgentSessionRecord(stored) + expect(record.providerHandleChain.map((link) => link.handle)).toEqual([ + { transport: 'claude-sdk', agent: 'claude', nativeId: 'sess-1', providerData: 'leaf-1' }, + { transport: 'claude-sdk', agent: 'claude', nativeId: 'sess-2' } + ]) + }) + + it('still reports a lease only the removed terminal handoff wrote', () => { + const record = agentSessionRecordFixture() + const stored = encodeAgentSessionRecord(record) + expect(decodePersistedAgentSessionRecord(stored)).toEqual({ record, normalized: false }) + const legacy = { ...stored, lease: { ...stored.lease, runtimeKind: 'tui' as const } } + expect(decodePersistedAgentSessionRecord(legacy)).toEqual({ + record: { ...record, lease: { ...record.lease, claimStatus: 'conflicted' } }, + normalized: true + }) + }) +}) + +describe('what this build writes', () => { + it('stores Claude and Codex handles in the typed form older builds read', () => { + const claude = claudeProviderHandle('sess-1', 'leaf-1') + const codex = codexProviderHandle('thread-1') + expect(encodePersistedAgentSessionProviderHandle(claude)).toEqual({ + provider: 'claude', + sessionId: 'sess-1', + leafUuid: 'leaf-1' + }) + expect(encodePersistedAgentSessionProviderHandle(claudeProviderHandle('sess-1', null))).toEqual( + { provider: 'claude', sessionId: 'sess-1', leafUuid: null } + ) + expect(encodePersistedAgentSessionProviderHandle(codex)).toEqual({ + provider: 'codex', + threadId: 'thread-1' + }) + for (const handle of [claude, codex]) { + const stored = encodePersistedAgentSessionProviderHandle(handle) + expect(olderBuildReadsHandle(stored)).toBe(true) + expect(decodePersistedAgentSessionProviderHandle(stored)).toEqual(handle) + } + }) + + it('keeps the persisted key and root strings older builds derived', () => { + // Fork seeds, superseded creations and resume offers store these; a new spelling strands them. + const claude = claudeProviderHandle('sess-1', 'leaf-1') + expect(agentSessionProviderHandleKey(claude)).toBe('claude:["sess-1","leaf-1"]') + expect(agentSessionProviderHandleKey(claudeProviderHandle('sess-1', null))).toBe( + 'claude:["sess-1",null]' + ) + expect(agentSessionProviderHandleRoot(claude)).toBe('claude:"sess-1"') + expect(agentSessionProviderHandleKey(codexProviderHandle('t'))).toBe('codex:"t"') + expect(agentSessionProviderHandleRoot(codexProviderHandle('t'))).toBe('codex:"t"') + }) + + it('round-trips another transport in the neutral form, which an older build refuses', () => { + const grok = { + transport: 'acp', + agent: 'grok', + nativeId: 'acp-session-1', + providerData: '{"cwd":"/repo"}' + } + const stored = encodePersistedAgentSessionProviderHandle(grok) + expect(stored).toEqual(grok) + expect(decodePersistedAgentSessionProviderHandle(JSON.parse(JSON.stringify(stored)))).toEqual( + grok + ) + // Unknown providers must never impersonate Codex on an older build. + expect(olderBuildReadsHandle(stored)).toBe(false) + expect(isAgentSessionHandleProvider(grok.agent)).toBe(false) + expect(agentSessionWireProviderHandle(grok)).toBeNull() + // Root, not provider data: provider data is resume state, not identity. + expect(agentSessionProviderHandleRoot(grok)).toBe('acp/grok:"acp-session-1"') + expect(agentSessionProviderHandleKey(grok)).toBe(agentSessionProviderHandleRoot(grok)) + }) + + it('refuses a stored handle that is not the one form its transport writes', () => { + for (const value of [ + // Claude or Codex in the neutral form: no build writes it. + { transport: 'claude-sdk', agent: 'claude', nativeId: 'sess-1' }, + { transport: 'codex-app-server', agent: 'codex', nativeId: 'thread-1' }, + // Both forms at once. + { provider: 'grok', transport: 'acp', agent: 'grok', nativeId: 'x' }, + { provider: 'claude', sessionId: 'sess-1' }, + { provider: 'claude', sessionId: 'sess-1', leafUuid: '' }, + { provider: 'codex', threadId: ' padded ' }, + { transport: 'a:b', agent: 'grok', nativeId: 'x' }, + { transport: 'acp', agent: 'grok', nativeId: 'x', providerData: '' }, + null + ]) { + expect(decodePersistedAgentSessionProviderHandle(value)).toBeNull() + } + }) +}) + +describe('the wire and journal forms', () => { + it('maps the attach wire handle both ways for Claude and Codex', () => { + const claudeWire = { kind: 'claude', sessionId: 'sess-1', leafUuid: null } as const + const codexWire = { kind: 'codex', threadId: 'thread-1' } as const + expect(agentSessionProviderHandleFromWire(claudeWire)).toEqual( + claudeProviderHandle('sess-1', null) + ) + expect(agentSessionWireProviderHandle(agentSessionProviderHandleFromWire(claudeWire))).toEqual( + claudeWire + ) + expect(agentSessionWireProviderHandle(agentSessionProviderHandleFromWire(codexWire))).toEqual( + codexWire + ) + }) + + it('records the journal rows exactly as before, and another transport as opaque', () => { + expect( + agentSessionJournalProviderHandle(claudeProviderHandle('sess-1', 'leaf-1'), 'claude') + ).toEqual({ kind: 'claude', sessionId: 'sess-1', leafUuid: 'leaf-1' }) + expect(agentSessionJournalProviderHandle(codexProviderHandle('t'), 'codex')).toEqual({ + kind: 'codex', + threadId: 't' + }) + expect(agentSessionJournalProviderHandle(null, 'codex')).toEqual({ + kind: 'opaque', + agent: 'codex', + value: 'pending' + }) + expect( + agentSessionJournalProviderHandle({ transport: 'acp', agent: 'grok', nativeId: 's' }, 'grok') + ).toEqual({ kind: 'opaque', agent: 'grok', value: 's' }) + }) +}) diff --git a/src/shared/agent-session-record-stored-form.ts b/src/shared/agent-session-record-stored-form.ts new file mode 100644 index 000000000000..ca6d242d24a6 --- /dev/null +++ b/src/shared/agent-session-record-stored-form.ts @@ -0,0 +1,41 @@ +/** + * An agent-session record between its stored row and memory. The stored row keeps what older + * builds read: Claude and Codex handles in their typed form and lease values only they wrote. + */ + +import { + leaseCarriesLegacyHandoffValues, + normalizeLegacyHandoffLease, + type PersistedAgentSessionRecord +} from './agent-session-legacy-handoff-lease' +import { + decodePersistedAgentSessionProviderHandleChain, + encodePersistedAgentSessionProviderHandleChain +} from './agent-session-provider-handle' +import type { AgentSessionRecord } from './agent-session-record' + +/** The in-memory record, plus whether decode changed anything the store must write back. A + * handle's stored form is not such a change: every build writes it the same way. */ +export function decodePersistedAgentSessionRecord(record: PersistedAgentSessionRecord): { + record: AgentSessionRecord + normalized: boolean +} { + const providerHandleChain = decodePersistedAgentSessionProviderHandleChain( + record.providerHandleChain + ) + if (!providerHandleChain) { + throw new Error('agent_session_provider_handle_invalid') + } + return { + record: { ...record, providerHandleChain, lease: normalizeLegacyHandoffLease(record.lease) }, + normalized: leaseCarriesLegacyHandoffValues(record.lease) + } +} + +/** The record as a row stores it; Claude and Codex handles keep the typed form older builds read. */ +export function encodeAgentSessionRecord(record: AgentSessionRecord): PersistedAgentSessionRecord { + return { + ...record, + providerHandleChain: encodePersistedAgentSessionProviderHandleChain(record.providerHandleChain) + } +} diff --git a/src/shared/agent-session-record.test-fixture.ts b/src/shared/agent-session-record.test-fixture.ts index 3e2270d368b1..68a190fbbcae 100644 --- a/src/shared/agent-session-record.test-fixture.ts +++ b/src/shared/agent-session-record.test-fixture.ts @@ -5,6 +5,7 @@ import { type AgentSessionLease, type AgentSessionRecord } from './agent-session-record' +import { claudeProviderHandle } from './agent-session-provider-handle-encoding' const OWNER_PROCESS = { hostId: 'local', @@ -56,7 +57,7 @@ export function agentSessionRecordFixture( origin: 'created', mintedAtFence: lease.runtimeFence, observedAt: 1_000, - handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null } + handle: claudeProviderHandle('provider-session-alpha-1', null) } ], accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/user/.claude' }, diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index b8280cf332da..963d79b14699 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -21,10 +21,11 @@ import { type AgentSessionConversationCommandRecord } from './agent-session-conversation-command' import { - isAgentSessionProviderHandleChain, + decodePersistedAgentSessionProviderHandleChain, type AgentSessionHandleProvider, type AgentSessionProviderHandleLink } from './agent-session-provider-handle' +import { agentSessionProviderHandleBelongsTo } from './agent-session-provider-handle-encoding' export const AGENT_SESSION_RECORD_SCHEMA_VERSION = 2 as const @@ -333,8 +334,8 @@ function isPersistedAgentSessionLease(value: unknown): value is PersistedAgentSe ) } -/** The on-disk shape, which still admits the removed terminal handoff's lease values. Decode - * through `normalizeLegacyHandoffRecord` before anything reads the lease. */ +/** The on-disk shape, which still admits the removed terminal handoff's lease values and stores + * handles in their typed form. Decode through `decodePersistedAgentSessionRecord` before use. */ export function isPersistedAgentSessionRecord( value: unknown ): value is PersistedAgentSessionRecord { @@ -347,7 +348,6 @@ export function isPersistedAgentSessionRecord( isAgentSessionId(record.sessionId) && isAgentSessionExecutionLocation(record.location) && (record.provider === 'claude' || record.provider === 'codex') && - isAgentSessionProviderHandleChain(record.providerHandleChain) && isAgentSessionAccountHome(record.accountHome) && (record.options === undefined || isAgentSessionOptions(record.options)) && (record.rewind === undefined || isAgentSessionRewindRecord(record.rewind)) && @@ -365,9 +365,12 @@ export function isPersistedAgentSessionRecord( return false } const validated = record as AgentSessionRecord - const head = validated.providerHandleChain.at(-1) + // The row holds stored handles; validate the chain they decode to. + const chain = decodePersistedAgentSessionProviderHandleChain(validated.providerHandleChain) + const head = chain?.at(-1) return ( - validated.providerHandleChain.every((link) => link.handle.provider === validated.provider) && + chain !== null && + chain.every((link) => agentSessionProviderHandleBelongsTo(link.handle, validated.provider)) && (validated.lease.claimStatus !== 'live' || (validated.lease.ownerProcess !== null && head?.linkId === validated.lease.provenHandleLinkId && diff --git a/tests/e2e/codex-child-approval-activity-row.unit.test.ts b/tests/e2e/codex-child-approval-activity-row.unit.test.ts index 27f00644ec1b..568b390dadb8 100644 --- a/tests/e2e/codex-child-approval-activity-row.unit.test.ts +++ b/tests/e2e/codex-child-approval-activity-row.unit.test.ts @@ -73,6 +73,7 @@ import { StructuredAgentSessionStatusBridge } from '../../src/renderer/src/compo import { resetStructuredAgentSessionStatusFeedsForTests } from '../../src/renderer/src/runtime/structured-agent-session-status-feed' import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' import { testEventSinkLogging } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../src/shared/agent-session-provider-handle-encoding' const SESSION = 'codex-child-approval' const CODEX_THREAD = 'thread-parent' @@ -139,7 +140,7 @@ async function openHost() { workspaceId: 'wt-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: CODEX_THREAD } + providerHandle: codexProviderHandle(CODEX_THREAD) }, now: tick, stateDirectory: join(root, SESSION) diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index b31c8f008cd1..73f634459063 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -6,8 +6,10 @@ import { AGENT_JOURNAL_THREAD_SCOPE, AGENT_SESSION_JOURNAL_SCHEMA_VERSION, type AgentJournalItemIdentity, - type AgentSessionJournalIdentity + type AgentSessionJournalIdentity, + type AgentSessionJournalProviderHandle } from '../../../src/shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' import Database from '../../../src/main/sqlite/sync-database' import { journalDatabasePath } from '../../../src/main/native-chat/agent-session-journal/journal-host-database' import { @@ -33,6 +35,16 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', + providerHandle: codexProviderHandle('thread-1') +} + +/** The identity as builds before the neutral provider handle took it. */ +type OlderJournalIdentity = Omit & { + providerHandle: AgentSessionJournalProviderHandle +} + +const OLDER_IDENTITY: OlderJournalIdentity = { + ...IDENTITY, providerHandle: { kind: 'codex', threadId: 'thread-1' } } @@ -163,7 +175,7 @@ type OlderJournal = { type OlderOpener = { open: (options: { - identity: AgentSessionJournalIdentity + identity: OlderJournalIdentity stateDirectory: string }) => Promise closeAll: () => Promise @@ -203,7 +215,7 @@ test("an older build opens this build's journal writable and appends to it; the ) const older = releaseExport<() => OlderOpener>(support, 'createTrackedJournalOpener')() try { - const downgraded = await older.open({ identity: IDENTITY, stateDirectory: directory }) + const downgraded = await older.open({ identity: OLDER_IDENTITY, stateDirectory: directory }) expect(downgraded.isReadOnly).toBe(false) expect(downgraded.cursor()).toEqual(wrote.cursor) expect(itemIds(downgraded)).toEqual(wrote.items) @@ -279,7 +291,7 @@ test("this build keeps a newer build's row kind and goes read-only; a build befo ) const older = releaseExport<() => OlderOpener>(support, 'createTrackedJournalOpener')() try { - await older.open({ identity: IDENTITY, stateDirectory: directory }) + await older.open({ identity: OLDER_IDENTITY, stateDirectory: directory }) } finally { await older.closeAll() } diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 1980ed95c55a..d427eb5109a8 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -64,6 +64,7 @@ import { } from './versioned-agent-session-wire' import { openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' // Why: a cold CI run extracts the baseline checkout before the first pairing. const SUITE_TIMEOUT_MS = 180_000 @@ -561,7 +562,7 @@ describe('cross-version structured agent sessions', () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW @@ -752,7 +753,7 @@ describe('cross-version structured agent sessions', () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), // A restarted host re-proves the thread it inherited; only the first // owner of a session may claim to have created it. origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', diff --git a/tests/e2e/structured-agent-session-read-owner.unit.test.ts b/tests/e2e/structured-agent-session-read-owner.unit.test.ts index b7734f2759d2..0220a33e59a7 100644 --- a/tests/e2e/structured-agent-session-read-owner.unit.test.ts +++ b/tests/e2e/structured-agent-session-read-owner.unit.test.ts @@ -33,6 +33,7 @@ import { resetStructuredAgentSessionReadOwnersForTests } from '../../src/renderer/src/components/native-chat/structured-agent-session-read-owner' import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../src/shared/agent-session-provider-handle-encoding' const SESSION = 'cursor-body-regression' const target = { kind: 'local' } as const @@ -56,7 +57,7 @@ async function fixture() { workspaceId: 'folder-workspace', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, 'journal') }) diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index 4968af98a934..abf2f994004a 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -26,6 +26,7 @@ import { readWorktreeStructuredActivationInventory } from '../../src/renderer/sr import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' import { openTestJournalHostDatabase } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../src/shared/agent-session-provider-handle-encoding' const WORKTREE = 'repo-1::/workspace/repo' @@ -44,7 +45,7 @@ function openHost(): void { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, observedAt: NOW From 8fb8aaf33ea72dd292e64aca79516404c44f8309 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:56:49 -0700 Subject: [PATCH 017/269] fix(native-chat): say a start refused beside an unstopped process plainly The rejection now reads "Couldn't stop {{agent}} from before. Send your message again to try once more." This kind has its own send-again step; every other failure keeps "Send your message to try again." --- ...ssion-claude-stop-exit-ends-record.test.ts | 2 +- ...tured-agent-session-codex-stop-row.test.ts | 2 +- .../agent-session-failure-words-text.ts | 5 +++++ .../src/i18n/en-runtime-required.json | 3 ++- src/renderer/src/i18n/locales/en.json | 3 ++- src/renderer/src/i18n/locales/es.json | 3 ++- src/renderer/src/i18n/locales/fr.json | 3 ++- src/renderer/src/i18n/locales/ja.json | 3 ++- src/renderer/src/i18n/locales/ko.json | 3 ++- src/renderer/src/i18n/locales/zh.json | 3 ++- src/shared/agent-session-failure-copy.ts | 3 ++- .../agent-session-failure-words.test.ts | 22 +++++++++++++++++++ src/shared/agent-session-failure-words.ts | 7 +++--- 13 files changed, 49 insertions(+), 13 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts index b0de80e26926..cfc2c27ca6be 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -431,7 +431,7 @@ it('rejects a message whose start meets a close still unverifiable, and starts n await eventually(async () => expect((await submission(next))?.dispatchState).toBe('rejected')) const rejected = (await submission(next))! expect(rejected.reason).toBe( - "Orca couldn't stop Claude's previous process. Send your message to try again." + "Couldn't stop Claude from before. Send your message again to try once more." ) expect(rejected.rejection).toMatchObject({ kind: 'restartFailed', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index a233424c4f73..1dbbac5282d8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -481,7 +481,7 @@ describe('a message after a Codex Stop whose exit was unproven', () => { ) ).toMatchObject({ dispatchState: 'rejected', - reason: "Orca couldn't stop Codex's previous process. Send your message to try again." + reason: "Couldn't stop Codex from before. Send your message again to try once more." }) ) expect(codex.connections).toHaveLength(1) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 34dab4ab94f6..59c4947c8b4f 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -27,6 +27,11 @@ const PIECES: Record translate('components.native-chat.failureWords.sendToTryAgain', COPY.sendToTryAgain), + sendAgainToTryOnceMore: () => + translate( + 'components.native-chat.failureWords.sendAgainToTryOnceMore', + COPY.sendAgainToTryOnceMore + ), couldNotStart: (values) => translate('components.native-chat.failureWords.couldNotStart', COPY.couldNotStart, values), couldNotRestart: (values) => diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 9e4e2b483899..ace6a78c2e5d 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2781,7 +2781,7 @@ "notDelivered": "This message was not delivered.", "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.", "notSignedIn": "{{agent}} is not signed in for the selected account.", - "previousExitUnverifiable": "Orca couldn't stop {{agent}}'s previous process.", + "previousExitUnverifiable": "Couldn't stop {{agent}} from before.", "providerExitedRejection": "{{agent}} stopped before this message was sent.", "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", "providerRateLimited": "{{agent}} is rate-limited and retrying.", @@ -2793,6 +2793,7 @@ "queueFull": "Too many messages were waiting for the agent, so this one was not sent.", "runCommandAgain": "Run /{{command}} again.", "sendToTryAgain": "Send your message to try again.", + "sendAgainToTryOnceMore": "Send your message again to try once more.", "signInFirst": "Sign in first.", "signInThenRunCommand": "Sign in, then run /{{command}} again.", "signInThenSend": "Sign in, then send your message again.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6cbf350f6a6c..4a14bbaa6ec3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17578,6 +17578,7 @@ "providerStartFailed": "{{agent}} stopped before it finished starting.", "runCommandAgain": "Run /{{command}} again.", "sendToTryAgain": "Send your message to try again.", + "sendAgainToTryOnceMore": "Send your message again to try once more.", "couldNotStart": "{{agent}} couldn't start.", "couldNotRestart": "{{agent}} couldn't restart.", "notSignedIn": "{{agent}} is not signed in for the selected account.", @@ -17631,7 +17632,7 @@ "providerRateLimited": "{{agent}} is rate-limited and retrying.", "providerRetrying": "{{agent}} hit a temporary problem and is retrying.", "providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.", - "previousExitUnverifiable": "Orca couldn't stop {{agent}}'s previous process." + "previousExitUnverifiable": "Couldn't stop {{agent}} from before." }, "writeNotice": { "notDoneReadHistory": "This chat's history couldn't be loaded.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 222c0faf8720..73195ae276f9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14948,6 +14948,7 @@ "providerStartFailed": "{{agent}} se detuvo antes de terminar de iniciarse.", "runCommandAgain": "Vuelva a ejecutar /{{command}}.", "sendToTryAgain": "Envíe su mensaje para volver a intentarlo.", + "sendAgainToTryOnceMore": "Vuelva a enviar su mensaje para intentarlo de nuevo.", "couldNotStart": "{{agent}} no pudo iniciarse.", "couldNotRestart": "{{agent}} no pudo reiniciarse.", "notSignedIn": "{{agent}} no tiene la sesión iniciada con la cuenta seleccionada.", @@ -15001,7 +15002,7 @@ "providerRateLimited": "{{agent}} alcanzó un límite de solicitudes y está reintentando.", "providerRetrying": "{{agent}} tuvo un problema temporal y está reintentando.", "providerRetryingQuoted": "{{agent}} está reintentando: {{detail}}.", - "previousExitUnverifiable": "Orca no pudo detener el proceso anterior de {{agent}}." + "previousExitUnverifiable": "No se pudo detener la ejecución anterior de {{agent}}." }, "writeNotice": { "notDoneReadHistory": "No se pudo cargar el historial de este chat.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index c5aa61e3dd64..63a4898f1dca 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17417,6 +17417,7 @@ "providerStartFailed": "{{agent}} s'est arrêté avant d'avoir fini de démarrer.", "runCommandAgain": "Relancez /{{command}}.", "sendToTryAgain": "Envoyez votre message pour réessayer.", + "sendAgainToTryOnceMore": "Renvoyez votre message pour réessayer.", "couldNotStart": "{{agent}} n'a pas pu démarrer.", "couldNotRestart": "{{agent}} n'a pas pu redémarrer.", "notSignedIn": "{{agent}} n'est pas connecté avec le compte sélectionné.", @@ -17470,7 +17471,7 @@ "providerRateLimited": "{{agent}} est limité en débit et réessaie.", "providerRetrying": "{{agent}} a rencontré un problème temporaire et réessaie.", "providerRetryingQuoted": "{{agent}} réessaie : {{detail}}.", - "previousExitUnverifiable": "Orca n'a pas pu arrêter le processus précédent de {{agent}}." + "previousExitUnverifiable": "Impossible d'arrêter l'exécution précédente de {{agent}}." }, "writeNotice": { "notDoneReadHistory": "L'historique de ce chat n'a pas pu être chargé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index ce58f420204d..28a9aebac429 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17354,6 +17354,7 @@ "providerStartFailed": "{{agent}} は起動が完了する前に停止しました。", "runCommandAgain": "/{{command}} をもう一度実行してください。", "sendToTryAgain": "もう一度試すには、メッセージを送信してください。", + "sendAgainToTryOnceMore": "メッセージをもう一度送信して、再度お試しください。", "couldNotStart": "{{agent}} を起動できませんでした。", "couldNotRestart": "{{agent}} を再起動できませんでした。", "notSignedIn": "{{agent}} は選択したアカウントでサインインしていません。", @@ -17407,7 +17408,7 @@ "providerRateLimited": "{{agent}} はレート制限を受けているため、再試行しています。", "providerRetrying": "{{agent}} で一時的な問題が発生したため、再試行しています。", "providerRetryingQuoted": "{{agent}} は再試行しています: {{detail}}。", - "previousExitUnverifiable": "Orca は {{agent}} の前のプロセスを停止できませんでした。" + "previousExitUnverifiable": "以前の {{agent}} を停止できませんでした。" }, "writeNotice": { "notDoneReadHistory": "このチャットの履歴を読み込めませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index bb3afd53c49b..fca3445f027e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17354,6 +17354,7 @@ "providerStartFailed": "{{agent}}이(가) 시작을 마치기 전에 중지되었습니다.", "runCommandAgain": "/{{command}}을(를) 다시 실행하세요.", "sendToTryAgain": "다시 시도하려면 메시지를 보내세요.", + "sendAgainToTryOnceMore": "메시지를 다시 보내 한 번 더 시도하세요.", "couldNotStart": "{{agent}}을(를) 시작하지 못했습니다.", "couldNotRestart": "{{agent}}을(를) 다시 시작하지 못했습니다.", "notSignedIn": "{{agent}}이(가) 선택한 계정으로 로그인되어 있지 않습니다.", @@ -17407,7 +17408,7 @@ "providerRateLimited": "{{agent}}이(가) 속도 제한에 걸려 다시 시도하고 있습니다.", "providerRetrying": "{{agent}}에 일시적인 문제가 발생하여 다시 시도하고 있습니다.", "providerRetryingQuoted": "{{agent}}이(가) 다시 시도하고 있습니다: {{detail}}.", - "previousExitUnverifiable": "Orca가 {{agent}}의 이전 프로세스를 중지하지 못했습니다." + "previousExitUnverifiable": "이전에 실행된 {{agent}}을(를) 중지하지 못했습니다." }, "writeNotice": { "notDoneReadHistory": "이 채팅의 기록을 불러오지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c2d9692fc920..d94177eb848c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17371,6 +17371,7 @@ "providerStartFailed": "{{agent}} 在完成启动前已停止。", "runCommandAgain": "请重新运行 /{{command}}。", "sendToTryAgain": "请发送您的消息以重试。", + "sendAgainToTryOnceMore": "请重新发送您的消息,再试一次。", "couldNotStart": "{{agent}} 无法启动。", "couldNotRestart": "{{agent}} 无法重新启动。", "notSignedIn": "{{agent}} 未使用所选账户登录。", @@ -17424,7 +17425,7 @@ "providerRateLimited": "{{agent}} 已被限流,正在重试。", "providerRetrying": "{{agent}} 遇到临时问题,正在重试。", "providerRetryingQuoted": "{{agent}} 正在重试:{{detail}}。", - "previousExitUnverifiable": "Orca 无法停止 {{agent}} 的上一个进程。" + "previousExitUnverifiable": "无法停止之前的 {{agent}}。" }, "writeNotice": { "notDoneReadHistory": "无法加载此聊天的历史记录。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index f40ffcfc1d2c..1e555c676d64 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -14,6 +14,7 @@ export const AGENT_SESSION_FAILURE_COPY = { providerStartFailed: '{{agent}} stopped before it finished starting.', runCommandAgain: 'Run /{{command}} again.', sendToTryAgain: 'Send your message to try again.', + sendAgainToTryOnceMore: 'Send your message again to try once more.', couldNotStart: "{{agent}} couldn't start.", couldNotRestart: "{{agent}} couldn't restart.", terminalAgentHoldsChat: TERMINAL_AGENT_HOLDS_CHAT, @@ -81,7 +82,7 @@ export const AGENT_SESSION_FAILURE_COPY = { providerRateLimited: '{{agent}} is rate-limited and retrying.', providerRetrying: '{{agent}} hit a temporary problem and is retrying.', providerRetryingQuoted: '{{agent}} is retrying: {{detail}}.', - previousExitUnverifiable: "Orca couldn't stop {{agent}}'s previous process." + previousExitUnverifiable: "Couldn't stop {{agent}} from before." } as const export type AgentSessionFailureCopyId = keyof typeof AGENT_SESSION_FAILURE_COPY diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index 98273628214d..4e6b81b265a8 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -151,6 +151,28 @@ describe('the words written beside a failure fact', () => { ).toBe("Codex couldn't start. Start a new chat to continue.") }) + it('says a start refused beside a process Orca could not stop in its own words', () => { + const refused = (context: { command?: 'compact'; retryControl?: boolean } = {}) => + agentSessionFailureSentence( + { + kind: 'restartFailed', + refusal: { + code: 'agent_session_ownership_unknown', + details: { reason: 'previousExitUnverifiable' } + } + }, + 'rejection', + { agentName: 'Claude', ...context } + ) + expect(refused()).toBe( + "Couldn't stop Claude from before. Send your message again to try once more." + ) + expect(refused({ command: 'compact' })).toBe( + "Couldn't stop Claude from before. Run /compact again." + ) + expect(refused({ retryControl: true })).toBe("Couldn't stop Claude from before.") + }) + it('names the exit a row reports differently from the message it left unsent', () => { expect( agentSessionFailureWords({ kind: 'providerExited' }, { surface: 'row', agentName: 'Claude' }) diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 1c9758d81820..74e6e543b533 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -130,12 +130,13 @@ function withRetryCause(sentence: string, cause: string | undefined): string { /** The next step after a start or restart that failed: the command, or the message, again. */ function startRetry( say: AgentSessionFailureSay, - { command, retryControl }: AgentSessionFailureWordsContext + { command, retryControl }: AgentSessionFailureWordsContext, + sendAgain: 'sendToTryAgain' | 'sendAgainToTryOnceMore' = 'sendToTryAgain' ): string[] { if (retryControl) { return [] } - return [command ? say('runCommandAgain', { command }) : say('sendToTryAgain')] + return [command ? say('runCommandAgain', { command }) : say(sendAgain)] } function couldNot(verb: 'couldNotStart' | 'couldNotRestart'): Sentence { @@ -149,7 +150,7 @@ function couldNot(verb: 'couldNotStart' | 'couldNotRestart'): Sentence { if (fact.refusal?.details?.reason === 'previousExitUnverifiable') { return joinSentences([ say('previousExitUnverifiable', agent(say, context)), - ...startRetry(say, context) + ...startRetry(say, context, 'sendAgainToTryOnceMore') ]) } const code = fact.refusal?.code From d8b831ee649106d56b202ed3d68346f1769eaf2b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:59:29 -0700 Subject: [PATCH 018/269] fix(native-chat): derive journal-row provider handles from the journal identity The journal row converter now takes the identity every caller already holds, so a row's handle has one obvious constructor. Tests that wrote the in-memory handle straight into journal rows now build it through that converter, and the processless Claude fixture names a not-yet-proved handle as null. --- ...ed-session-acquisition-processless.test.ts | 2 +- .../journal-epoch-replacement.ts | 5 +---- .../journal-epoch-rollover.ts | 5 +---- .../journal-row-builders.ts | 2 +- .../journal-stop-and-resume-rows.test.ts | 7 ++++-- .../journal-submission-queued-link.test.ts | 7 ++++-- .../journal-unknown-row-kind.test.ts | 7 ++++-- ...ession-history-forward-read-budget.test.ts | 7 ++++-- .../agent-session-provider-handle-encoding.ts | 18 ++++++++++----- .../agent-session-record-stored-form.test.ts | 22 +++++++++++++------ 10 files changed, 52 insertions(+), 30 deletions(-) diff --git a/src/main/claude/claude-structured-session-acquisition-processless.test.ts b/src/main/claude/claude-structured-session-acquisition-processless.test.ts index eb4babdb9aca..a75f82b72db5 100644 --- a/src/main/claude/claude-structured-session-acquisition-processless.test.ts +++ b/src/main/claude/claude-structured-session-acquisition-processless.test.ts @@ -13,7 +13,7 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'workspace-1', hostId: 'local', agent: 'claude', - providerHandle: { kind: 'opaque', agent: 'claude', value: 'pending' } + providerHandle: null } describe('Claude structured processless acquisition', () => { diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index b2754e33acaf..c378e343d7a7 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -59,10 +59,7 @@ export function replaceJournalEpoch(input: { const epochRow: JournalRow = { kind: 'epoch', reason: input.reason, - providerHandle: agentSessionJournalProviderHandle( - input.identity.providerHandle, - input.identity.agent - ), + providerHandle: agentSessionJournalProviderHandle(input.identity), ...journalRowBase(epoch, 1, input.fence, input.now()) } const rows: JournalRow[] = [epochRow] diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts index 26296f89d109..a1eaac157a53 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts @@ -33,10 +33,7 @@ export function publishNewEpoch(input: { const row: JournalRow = { kind: 'epoch', reason: input.reason, - providerHandle: agentSessionJournalProviderHandle( - input.identity.providerHandle, - input.identity.agent - ), + providerHandle: agentSessionJournalProviderHandle(input.identity), // Carries no body: an older host must keep reading a turn-free session past row 1. v: journalRowSchemaVersion([]), epoch: input.epoch, diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 2cf3f60b0e6f..ed40d4909517 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -87,7 +87,7 @@ export function journalSubmissionRowBuilder( const queuedMessageId = consume?.messageId ?? input.queuedMessageId return buildJournalSubmissionRow({ state: state(), - providerHandle: agentSessionJournalProviderHandle(identity.providerHandle, identity.agent), + providerHandle: agentSessionJournalProviderHandle(identity), ...input, ...(queuedMessageId !== undefined ? { queuedMessageId } : {}), seq, diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts index a7fedb6a0a10..a5d17a77069c 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts @@ -23,7 +23,10 @@ import { buildJournalQueueResumeRow, buildJournalStopEventRow } from './journal-stop-and-resume-rows' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { + agentSessionJournalProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-s', @@ -65,7 +68,7 @@ function epochWith( { kind: 'epoch', reason, - providerHandle: IDENTITY.providerHandle, + providerHandle: agentSessionJournalProviderHandle(IDENTITY), ...journalRowBase(EPOCH, 1, 1, 1) } ] diff --git a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts index 1159a37ef33f..f9588b71ba3a 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts @@ -15,7 +15,10 @@ import { createJournalReducerState, applyJournalRow } from './journal-reducer' import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' import { createTrackedJournalOpener } from './journal-host-database-test-support' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { + agentSessionJournalProviderHandle, + claudeProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-q', @@ -133,7 +136,7 @@ describe('the persisted row', () => { ts: 1, clientMessageId: 'handoff-1', payloadFingerprint: 'fp', - providerHandle: IDENTITY.providerHandle, + providerHandle: agentSessionJournalProviderHandle(IDENTITY), body: BODY, queuedMessageId: 'draft-1' } diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts index 55a8a0460858..ea9bb14ef971 100644 --- a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts @@ -20,7 +20,10 @@ import { openTestJournalHostDatabase } from './journal-host-database-test-support' import { parseJournalRow, type JournalRow } from './journal-row-schema' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { + agentSessionJournalProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-newer-kind', @@ -106,7 +109,7 @@ async function journalWithOneItem(): Promise { * fails to compile. */ function rowOfEveryKind(): { [Kind in JournalRow['kind']]: Extract } { const base = { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, epoch: 'epoch-1', fence: 1, ts: 1 } - const providerHandle = IDENTITY.providerHandle + const providerHandle = agentSessionJournalProviderHandle(IDENTITY) return { epoch: { ...base, seq: 1, kind: 'epoch', reason: 'session_created', providerHandle }, item: { diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts index 1a1e7fda2cd8..f94624c26c6c 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts @@ -20,7 +20,10 @@ import { } from '../agent-session-journal/journal-host-database-test-support' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' import { readAgentSessionHistory } from './agent-session-history-page' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { + agentSessionJournalProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' const identity: AgentSessionJournalIdentity = { sessionId: 'bounded-catch-up', @@ -56,7 +59,7 @@ async function seedJournal(count: number) { kind: 'epoch', seq: 1, reason: 'session_created', - providerHandle: identity.providerHandle + providerHandle: agentSessionJournalProviderHandle(identity) }) for (let index = 0; index < count; index += 1) { insertTestJournalRow(db, identity.sessionId, { diff --git a/src/shared/agent-session-provider-handle-encoding.ts b/src/shared/agent-session-provider-handle-encoding.ts index e15cc5339d86..77a71a32b14e 100644 --- a/src/shared/agent-session-provider-handle-encoding.ts +++ b/src/shared/agent-session-provider-handle-encoding.ts @@ -14,7 +14,10 @@ import type { AgentSessionProviderHandle, AgentSessionProviderHandleNamespace } from './agent-session-provider-handle' -import type { AgentSessionJournalProviderHandle, AgentType } from './agent-session-journal-types' +import type { + AgentSessionJournalIdentity, + AgentSessionJournalProviderHandle +} from './agent-session-journal-types' export const CLAUDE_STRUCTURED_HANDLE_NAMESPACE = { transport: 'claude-sdk', @@ -267,13 +270,18 @@ export function agentSessionWireProviderHandle( /** What a journal row records before the provider has proved any handle. */ export const AGENT_SESSION_PENDING_PROVIDER_HANDLE_VALUE = 'pending' -/** The handle a journal row records. Write-only: rows keep it for diagnosis, nothing reads it. */ +/** The handle a journal row records for this identity. Write-only: rows keep it for diagnosis, + * nothing reads it. */ export function agentSessionJournalProviderHandle( - handle: AgentSessionProviderHandle | null, - agent: AgentType + identity: Pick ): AgentSessionJournalProviderHandle { + const handle = identity.providerHandle if (!handle) { - return { kind: 'opaque', agent, value: AGENT_SESSION_PENDING_PROVIDER_HANDLE_VALUE } + return { + kind: 'opaque', + agent: identity.agent, + value: AGENT_SESSION_PENDING_PROVIDER_HANDLE_VALUE + } } return ( agentSessionWireProviderHandle(handle) ?? { diff --git a/src/shared/agent-session-record-stored-form.test.ts b/src/shared/agent-session-record-stored-form.test.ts index 5070ae67d47e..2107740219e5 100644 --- a/src/shared/agent-session-record-stored-form.test.ts +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -222,19 +222,27 @@ describe('the wire and journal forms', () => { it('records the journal rows exactly as before, and another transport as opaque', () => { expect( - agentSessionJournalProviderHandle(claudeProviderHandle('sess-1', 'leaf-1'), 'claude') + agentSessionJournalProviderHandle({ + agent: 'claude', + providerHandle: claudeProviderHandle('sess-1', 'leaf-1') + }) ).toEqual({ kind: 'claude', sessionId: 'sess-1', leafUuid: 'leaf-1' }) - expect(agentSessionJournalProviderHandle(codexProviderHandle('t'), 'codex')).toEqual({ - kind: 'codex', - threadId: 't' - }) - expect(agentSessionJournalProviderHandle(null, 'codex')).toEqual({ + expect( + agentSessionJournalProviderHandle({ + agent: 'codex', + providerHandle: codexProviderHandle('t') + }) + ).toEqual({ kind: 'codex', threadId: 't' }) + expect(agentSessionJournalProviderHandle({ agent: 'codex', providerHandle: null })).toEqual({ kind: 'opaque', agent: 'codex', value: 'pending' }) expect( - agentSessionJournalProviderHandle({ transport: 'acp', agent: 'grok', nativeId: 's' }, 'grok') + agentSessionJournalProviderHandle({ + agent: 'grok', + providerHandle: { transport: 'acp', agent: 'grok', nativeId: 's' } + }) ).toEqual({ kind: 'opaque', agent: 'grok', value: 's' }) }) }) From 00001f1fd74ac9fac9323ccd49b5be7294eeb6af Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:46:17 -0700 Subject: [PATCH 019/269] Move provider process supervision and stream reading out of Codex --- config/scripts/ci-shard-timings.json | 4 ++-- config/scripts/pr-code-change-scope.mjs | 2 +- config/scripts/pr-code-change-scope.test.mjs | 2 +- config/tsconfig.cli.json | 4 ++-- .../claude-agent-sdk-process-spawn.test.ts | 8 +++---- .../claude/claude-agent-sdk-process-spawn.ts | 2 +- .../claude-child-exit-proof-ladder.test.ts | 2 +- .../claude/claude-child-exit-proof-ladder.ts | 4 ++-- ...claude-supervised-stop.integration.test.ts | 2 +- .../codex/codex-app-server-connection.test.ts | 2 +- src/main/codex/codex-app-server-connection.ts | 24 +++++++------------ src/main/codex/codex-app-server-jsonl.ts | 12 ---------- .../codex/codex-app-server-record-dispatch.ts | 6 ++--- src/main/codex/codex-app-server-session.ts | 6 ++--- ...ctured-agent-session-host-teardown.test.ts | 2 +- .../structured-agent-session-host-teardown.ts | 2 +- ...-agent-session-recovery-resolution.test.ts | 2 +- ...tured-agent-session-recovery-resolution.ts | 2 +- .../provider-process/provider-json-record.ts | 3 +++ .../provider-process-exit-deadline.ts} | 2 +- .../provider-process-launch.ts | 10 ++++++++ ...er-process-supervisor.integration.test.ts} | 2 +- .../provider-process-supervisor.test.ts} | 6 ++--- .../provider-process-supervisor.ts} | 6 ++--- .../provider-process-teardown.test.ts} | 18 +++++++------- .../provider-process-teardown.ts} | 17 ++++++------- .../provider-record-reader.ts} | 6 ++--- .../child-process-import-allowlist.txt | 4 ++-- 28 files changed, 76 insertions(+), 86 deletions(-) delete mode 100644 src/main/codex/codex-app-server-jsonl.ts create mode 100644 src/main/provider-process/provider-json-record.ts rename src/main/{codex/codex-process-exit-deadline.ts => provider-process/provider-process-exit-deadline.ts} (86%) create mode 100644 src/main/provider-process/provider-process-launch.ts rename src/main/{codex/codex-app-server-posix-supervisor.integration.test.ts => provider-process/provider-process-supervisor.integration.test.ts} (99%) rename src/main/{codex/codex-app-server-posix-supervisor.test.ts => provider-process/provider-process-supervisor.test.ts} (94%) rename src/main/{codex/codex-app-server-posix-supervisor.ts => provider-process/provider-process-supervisor.ts} (98%) rename src/main/{codex/codex-app-server-process-teardown.test.ts => provider-process/provider-process-teardown.test.ts} (90%) rename src/main/{codex/codex-app-server-process-teardown.ts => provider-process/provider-process-teardown.ts} (90%) rename src/main/{codex/codex-app-server-record-reader.ts => provider-process/provider-record-reader.ts} (90%) diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 73d0226943b8..a56932a44795 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -1211,8 +1211,8 @@ "src/main/codex/codex-app-server-capability-cache.test.ts": 64, "src/main/codex/codex-app-server-client.test.ts": 1502, "src/main/codex/codex-app-server-connection.test.ts": 5199, - "src/main/codex/codex-app-server-posix-supervisor.test.ts": 38, - "src/main/codex/codex-app-server-process-teardown.test.ts": 71, + "src/main/provider-process/provider-process-supervisor.test.ts": 38, + "src/main/provider-process/provider-process-teardown.test.ts": 71, "src/main/codex/codex-app-server-session.test.ts": 129, "src/main/codex/codex-app-server-teardown.integration.test.ts": 21677, "src/main/codex/codex-background-command-tracker.test.ts": 221, diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 800854c8008f..6064ff38a2fb 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -70,7 +70,7 @@ const CODEX_INDEX_HEAL_CONTRACT_PREFIXES = [ 'src/main/codex/codex-state-db', 'src/main/sqlite/sync-database', 'src/main/codex/codex-app-server-capability-signal', - 'src/main/codex/codex-process-exit-deadline', + 'src/main/provider-process/provider-process-exit-deadline', 'src/main/codex/codex-session-backfill', 'src/main/codex/codex-session-index-heal-state', 'src/main/codex-cli/command', diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 297d1f8e6713..28fd0194a3b5 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -169,7 +169,7 @@ describe('per-job path classification', () => { // Keep the real-binary gate live when a transport or launch dependency changes. for (const file of [ 'src/main/codex/codex-app-server-capability-signal.ts', - 'src/main/codex/codex-process-exit-deadline.ts', + 'src/main/provider-process/provider-process-exit-deadline.ts', 'src/main/codex/codex-session-backfill.ts', 'src/main/codex/codex-session-index-heal-state.ts', 'src/main/codex-cli/command.ts', diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 44edcfd90b7e..0bd07d1b6c15 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -118,7 +118,7 @@ "../src/main/codex/codex-app-server-capability-signal.ts", "../src/main/codex/codex-app-server-client.ts", "../src/main/codex/codex-app-server-process-tree-kill.ts", - "../src/main/codex/codex-app-server-record-reader.ts", + "../src/main/provider-process/provider-record-reader.ts", "../src/main/codex/codex-app-server-session.ts", "../src/main/codex/codex-config-mirror.ts", "../src/main/codex/codex-config-mirror-merge.ts", @@ -149,7 +149,7 @@ "../src/main/codex/codex-hook-retired-commands.ts", "../src/main/codex/codex-hook-wsl-runtime.ts", "../src/main/codex/codex-managed-trust-reconciliation.ts", - "../src/main/codex/codex-process-exit-deadline.ts", + "../src/main/provider-process/provider-process-exit-deadline.ts", "../src/main/codex/codex-real-home-background-grant.ts", "../src/main/codex/codex-real-home-entry-trust.ts", "../src/main/codex/codex-real-home-hook-entry-plan.ts", diff --git a/src/main/claude/claude-agent-sdk-process-spawn.test.ts b/src/main/claude/claude-agent-sdk-process-spawn.test.ts index 1b6c192d433a..af1da7cd8bf0 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.test.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.test.ts @@ -4,12 +4,12 @@ import { describe, expect, it, vi } from 'vitest' import type { SpawnOptions as SdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk' import { resolveSpawn, type spawnProcess } from '../../shared/child-process/run-process' import type { ProcessSpec } from '../../shared/child-process/process-spec' -import type * as ProviderSupervisor from '../codex/codex-app-server-posix-supervisor' -import { createProviderSpawnSpec } from '../codex/codex-app-server-posix-supervisor' +import type * as ProviderSupervisor from '../provider-process/provider-process-supervisor' +import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor' import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' import { proveClaudeChildExitWithReaper } from './claude-child-exit-proof-ladder' -vi.mock('../codex/codex-app-server-posix-supervisor', async (importOriginal) => { +vi.mock('../provider-process/provider-process-supervisor', async (importOriginal) => { const actual = await importOriginal() return { ...actual, createProviderSpawnSpec: vi.fn(actual.createProviderSpawnSpec) } }) @@ -110,7 +110,7 @@ describe('claude agent SDK process spawn', () => { 'stops Claude by the spawn spec\u2019s supervision on $platform, never the platform', async ({ platform, specSupervised }) => { const actual = await vi.importActual( - '../codex/codex-app-server-posix-supervisor' + '../provider-process/provider-process-supervisor' ) vi.mocked(createProviderSpawnSpec).mockImplementationOnce((...args) => ({ ...actual.createProviderSpawnSpec(...args), diff --git a/src/main/claude/claude-agent-sdk-process-spawn.ts b/src/main/claude/claude-agent-sdk-process-spawn.ts index bbb4b295aade..96fefa0e275b 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.ts @@ -1,6 +1,6 @@ import type { SpawnOptions as ClaudeAgentSdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk' import { spawnProcess } from '../../shared/child-process/run-process' -import { createProviderSpawnSpec } from '../codex/codex-app-server-posix-supervisor' +import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor' /** Derived rather than imported: only src/shared/child-process may name node:child_process. */ type ClaudeCodeChild = ReturnType diff --git a/src/main/claude/claude-child-exit-proof-ladder.test.ts b/src/main/claude/claude-child-exit-proof-ladder.test.ts index 2d8da7f629dc..6a74a0b0c6b6 100644 --- a/src/main/claude/claude-child-exit-proof-ladder.test.ts +++ b/src/main/claude/claude-child-exit-proof-ladder.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../codex/codex-app-server-posix-supervisor' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' import type { ClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' import { proveClaudeChildExitWithReaper } from './claude-child-exit-proof-ladder' diff --git a/src/main/claude/claude-child-exit-proof-ladder.ts b/src/main/claude/claude-child-exit-proof-ladder.ts index 37d4e47508d7..41015b0192f0 100644 --- a/src/main/claude/claude-child-exit-proof-ladder.ts +++ b/src/main/claude/claude-child-exit-proof-ladder.ts @@ -1,6 +1,6 @@ import type { SpawnedProcess } from '../../shared/child-process/run-process' -import { waitForProcessExitUntil } from '../codex/codex-process-exit-deadline' -import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../codex/codex-app-server-posix-supervisor' +import { waitForProcessExitUntil } from '../provider-process/provider-process-exit-deadline' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' import type { ClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' export const GRACEFUL_EXIT_MS = 1_500 diff --git a/src/main/claude/claude-supervised-stop.integration.test.ts b/src/main/claude/claude-supervised-stop.integration.test.ts index d0a5f9ea0cc4..6f0749e79e79 100644 --- a/src/main/claude/claude-supervised-stop.integration.test.ts +++ b/src/main/claude/claude-supervised-stop.integration.test.ts @@ -10,7 +10,7 @@ import { PROVIDER_SIGTERM_GRACE_MS, PROVIDER_STDIN_END_GRACE_MS, PROVIDER_SUPERVISOR_MAX_STOP_MS -} from '../codex/codex-app-server-posix-supervisor' +} from '../provider-process/provider-process-supervisor' import { proveClaudeChildExit } from './claude-agent-sdk-exit-proof' import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index d36cfac47a44..a5e279039830 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -11,7 +11,7 @@ import { type CodexAppServerConnection, type CodexAppServerConnectionHandlers } from './codex-app-server-connection' -import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from './codex-app-server-posix-supervisor' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' // close() waits out the supervisor's own stop before forcing the tree. diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 3e7d16ea1bb0..cae5a81aa59d 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -1,20 +1,21 @@ import { spawnProcess } from '../../shared/child-process/run-process' import { RetryableProcessExitProof } from '../../shared/child-process/retryable-process-exit-proof' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' import { createProviderSpawnSpec, PROVIDER_SUPERVISOR_MAX_STOP_MS -} from './codex-app-server-posix-supervisor' +} from '../provider-process/provider-process-supervisor' import { buildCodexAppServerExitError } from './codex-app-server-exit-error' import { initializeCodexAppServerConnection } from './codex-app-server-handshake' import { CodexAppServerHandshakeExitUnprovenError } from './codex-app-server-handshake-exit-proof' -import { terminateCodexAppServerProcessTree } from './codex-app-server-process-teardown' -import { waitForProcessExitUntil } from './codex-process-exit-deadline' +import { terminateProviderProcessTree } from '../provider-process/provider-process-teardown' +import { waitForProcessExitUntil } from '../provider-process/provider-process-exit-deadline' import { CodexAppServerTimeoutError, CodexAppServerUnsupportedError } from './codex-app-server-session' import { createCodexAppServerRecordDispatcher } from './codex-app-server-record-dispatch' -import { createCodexAppServerRecordReader } from './codex-app-server-record-reader' +import { createProviderRecordReader } from '../provider-process/provider-record-reader' import type { CodexAppServerConnection, CodexAppServerConnectionHandlers @@ -34,16 +35,7 @@ export { CodexAppServerFrameSizeError } from './codex-app-server-frame-size-erro // Structured chat needs a persistent bidirectional child and per-request deadlines; // the request-scoped app-server runner cannot carry approvals or streamed turns. -export type CodexAppServerLaunch = { - command: string - args: string[] - /** Workspace directory used by the provider process itself. */ - cwd?: string - /** Overlay on the inherited environment — the pinned CODEX_HOME lives here. */ - env?: Record - /** Keys stripped after the overlay, matching `CodexAppServerInvocation`. */ - envToDelete?: readonly string[] -} +export type CodexAppServerLaunch = ProviderProcessLaunch const DEFAULT_REQUEST_TIMEOUT_MS = 30_000 export const GRACEFUL_EXIT_MS = 1_500 @@ -70,7 +62,7 @@ export async function openCodexAppServerConnection( function terminateProcessTree(): Promise { // The supervisor and provider own separate POSIX groups so the supervisor can prove the // provider group empty before relaying its exit. Forced wrapper teardown uses descendant proof. - return terminateCodexAppServerProcessTree(child) + return terminateProviderProcessTree(child) } let stderrTail = '' @@ -156,7 +148,7 @@ export async function openCodexAppServerConnection( void terminateProcessTree() }) - const recordReader = createCodexAppServerRecordReader({ + const recordReader = createProviderRecordReader({ stdout: child.stdout, onRecord: (parsed, line) => { if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { diff --git a/src/main/codex/codex-app-server-jsonl.ts b/src/main/codex/codex-app-server-jsonl.ts deleted file mode 100644 index 3a65e1fb1389..000000000000 --- a/src/main/codex/codex-app-server-jsonl.ts +++ /dev/null @@ -1,12 +0,0 @@ -export function isAppServerRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -export function parseCodexAppServerJsonLine(line: string): Record | null { - try { - const parsed: unknown = JSON.parse(line) - return isAppServerRecord(parsed) ? parsed : null - } catch { - return null - } -} diff --git a/src/main/codex/codex-app-server-record-dispatch.ts b/src/main/codex/codex-app-server-record-dispatch.ts index cbac80f3643e..313fbda003cd 100644 --- a/src/main/codex/codex-app-server-record-dispatch.ts +++ b/src/main/codex/codex-app-server-record-dispatch.ts @@ -1,7 +1,7 @@ import type { NdjsonRejectedRecord } from '../../shared/main-process-ndjson-framer' import type { CodexAppServerConnectionHandlers } from './codex-app-server-connection-types' import { CodexAppServerFrameSizeError } from './codex-app-server-frame-size-error' -import { isAppServerRecord } from './codex-app-server-jsonl' +import { isProviderRecord } from '../provider-process/provider-json-record' import { CodexAppServerRequestError } from './codex-app-server-request-error' import { CodexAppServerUnsupportedError, @@ -92,7 +92,7 @@ export function createCodexAppServerRecordDispatcher(input: { // interpreted this reply, and it already reported its own outcome. const timedOutMethod = timedOutMethods.get(message.id) timedOutMethods.delete(message.id) - const error = isAppServerRecord(message.error) ? message.error.message : undefined + const error = isProviderRecord(message.error) ? message.error.message : undefined console.warn( timedOutMethod ? `[codex-app-server] late reply to ${timedOutMethod} after timeout (id ${message.id})` @@ -104,7 +104,7 @@ export function createCodexAppServerRecordDispatcher(input: { pending.delete(message.id) clearTimeout(waiter.timer) const error = message.error - if (isAppServerRecord(error)) { + if (isProviderRecord(error)) { const detail = typeof error.message === 'string' ? error.message : 'unknown error' waiter.reject( isCodexMethodNotFoundError(error) diff --git a/src/main/codex/codex-app-server-session.ts b/src/main/codex/codex-app-server-session.ts index 389cd20516cb..8f2bcbecad2b 100644 --- a/src/main/codex/codex-app-server-session.ts +++ b/src/main/codex/codex-app-server-session.ts @@ -1,5 +1,5 @@ import type { ChildProcessWithoutNullStreams } from 'node:child_process' -import { waitForProcessExitUntil } from './codex-process-exit-deadline' +import { waitForProcessExitUntil } from '../provider-process/provider-process-exit-deadline' import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { @@ -7,7 +7,7 @@ import { spawnCodexAppServerProcess, type CodexAppServerSpawn } from './codex-app-server-process-tree-kill' -import { createCodexAppServerRecordReader } from './codex-app-server-record-reader' +import { createProviderRecordReader } from '../provider-process/provider-record-reader' // Why: `codex app-server` is Orca's sanctioned RPC surface into Codex-owned // state (hook trust hashes, the sqlite thread index). This module owns the @@ -154,7 +154,7 @@ export async function runCodexAppServerSession( failPending(error) }) - createCodexAppServerRecordReader({ + createProviderRecordReader({ stdout: child.stdout, onRecord: (parsed) => { if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts index e6f62d6d780c..dde5f82dae59 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts @@ -5,7 +5,7 @@ import { SUPERVISED_GRACEFUL_EXIT_MS } from '../../claude/claude-child-exit-proof-ladder' import { GRACEFUL_EXIT_MS as CODEX_WINDOWS_GRACEFUL_EXIT_MS } from '../../codex/codex-app-server-connection' -import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../codex/codex-app-server-posix-supervisor' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../provider-process/provider-process-supervisor' import { SNAPSHOT_DRAIN_TIMEOUT_MS } from './structured-agent-session-eviction' import { CHILD_EVICTION_TIMEOUT_MS, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index 65bf219cf79d..bbae1b4db163 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 @@ -7,7 +7,7 @@ import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' import { SUPERVISED_GRACEFUL_EXIT_MS } from '../../claude/claude-child-exit-proof-ladder' -import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../codex/codex-app-server-posix-supervisor' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../provider-process/provider-process-supervisor' import { SNAPSHOT_DRAIN_TIMEOUT_MS } from './structured-agent-session-eviction' import type { StructuredAgentSessionRestartResume } from './structured-agent-session-restart-resume-host' import { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts index 8e3be9d1f3d7..8d44f9708b5f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts @@ -10,7 +10,7 @@ import { } from '../../runtime/agent-session-older-build-lease.test-fixture' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { supervisedPosixLaunch } from '../../codex/codex-app-server-posix-supervisor' +import { supervisedPosixLaunch } from '../../provider-process/provider-process-supervisor' import { resolveStructuredSessionRecovery, type StructuredSessionRecoveryResolutionDeps diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts index 385d438f7a04..5dbd9a4071b7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts @@ -15,7 +15,7 @@ import { type AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../codex/codex-app-server-posix-supervisor' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../provider-process/provider-process-supervisor' import { releaseUnprovenAgentSessionOwner } from '../../runtime/agent-session-lease-transitions' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' diff --git a/src/main/provider-process/provider-json-record.ts b/src/main/provider-process/provider-json-record.ts new file mode 100644 index 000000000000..cc57d7a36dd3 --- /dev/null +++ b/src/main/provider-process/provider-json-record.ts @@ -0,0 +1,3 @@ +export function isProviderRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/codex/codex-process-exit-deadline.ts b/src/main/provider-process/provider-process-exit-deadline.ts similarity index 86% rename from src/main/codex/codex-process-exit-deadline.ts rename to src/main/provider-process/provider-process-exit-deadline.ts index d6eaf1eda2cf..40d099a736fd 100644 --- a/src/main/codex/codex-process-exit-deadline.ts +++ b/src/main/provider-process/provider-process-exit-deadline.ts @@ -10,7 +10,7 @@ export async function waitForProcessExitUntil( await Promise.race([exitPromise, timeout]) } finally { // Why: this runs in a short-lived entry; a live grace timer delays the - // parent spawnSync even after the app-server process has already exited. + // parent spawnSync even after the provider process has already exited. if (timer !== undefined) { clearTimeout(timer) } diff --git a/src/main/provider-process/provider-process-launch.ts b/src/main/provider-process/provider-process-launch.ts new file mode 100644 index 000000000000..326e1d17adcb --- /dev/null +++ b/src/main/provider-process/provider-process-launch.ts @@ -0,0 +1,10 @@ +export type ProviderProcessLaunch = { + command: string + args: string[] + /** Workspace directory used by the provider process itself. */ + cwd?: string + /** Overlay on the inherited environment. */ + env?: Record + /** Keys stripped after the overlay. */ + envToDelete?: readonly string[] +} diff --git a/src/main/codex/codex-app-server-posix-supervisor.integration.test.ts b/src/main/provider-process/provider-process-supervisor.integration.test.ts similarity index 99% rename from src/main/codex/codex-app-server-posix-supervisor.integration.test.ts rename to src/main/provider-process/provider-process-supervisor.integration.test.ts index 846afaa973f4..b3b7f068b7d3 100644 --- a/src/main/codex/codex-app-server-posix-supervisor.integration.test.ts +++ b/src/main/provider-process/provider-process-supervisor.integration.test.ts @@ -10,7 +10,7 @@ import { PROVIDER_SUPERVISOR_MAX_STOP_MS, supervisedPosixLaunch, type ProviderSupervisorOptions -} from './codex-app-server-posix-supervisor' +} from './provider-process-supervisor' // The provider leads its own group; its grandchild shares that group and ignores SIGTERM. const PROVIDER = String.raw` diff --git a/src/main/codex/codex-app-server-posix-supervisor.test.ts b/src/main/provider-process/provider-process-supervisor.test.ts similarity index 94% rename from src/main/codex/codex-app-server-posix-supervisor.test.ts rename to src/main/provider-process/provider-process-supervisor.test.ts index 99cbaf100088..c65f5518f3f3 100644 --- a/src/main/codex/codex-app-server-posix-supervisor.test.ts +++ b/src/main/provider-process/provider-process-supervisor.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from 'vitest' -import type { CodexAppServerLaunch } from './codex-app-server-connection' +import type { ProviderProcessLaunch } from './provider-process-launch' import { createProviderSpawnSpec, POSIX_PROVIDER_SUPERVISOR_SCRIPT, PROVIDER_SIGTERM_GRACE_MS, PROVIDER_STDIN_END_GRACE_MS, supervisedPosixLaunch -} from './codex-app-server-posix-supervisor' +} from './provider-process-supervisor' -const launch: CodexAppServerLaunch = { +const launch: ProviderProcessLaunch = { command: '/opt/codex', args: ['app-server', '--flag'], cwd: '/work/repo', diff --git a/src/main/codex/codex-app-server-posix-supervisor.ts b/src/main/provider-process/provider-process-supervisor.ts similarity index 98% rename from src/main/codex/codex-app-server-posix-supervisor.ts rename to src/main/provider-process/provider-process-supervisor.ts index 21dd56aeb5b5..459c379b6881 100644 --- a/src/main/codex/codex-app-server-posix-supervisor.ts +++ b/src/main/provider-process/provider-process-supervisor.ts @@ -1,4 +1,4 @@ -import type { CodexAppServerLaunch } from './codex-app-server-connection' +import type { ProviderProcessLaunch } from './provider-process-launch' /** Time the provider gets to exit on its own after its stdin ends, before SIGTERM. */ export const PROVIDER_STDIN_END_GRACE_MS = 1_000 @@ -139,7 +139,7 @@ function assertGraceWithin(name: string, graceMs: number, maxMs: number): void { } export function supervisedPosixLaunch( - launch: CodexAppServerLaunch, + launch: ProviderProcessLaunch, childEnv: NodeJS.ProcessEnv, { cwd = launch.cwd ?? process.cwd(), @@ -174,7 +174,7 @@ export function supervisedPosixLaunch( } export function createProviderSpawnSpec( - launch: CodexAppServerLaunch, + launch: ProviderProcessLaunch, childEnv: NodeJS.ProcessEnv, platform: NodeJS.Platform ): { diff --git a/src/main/codex/codex-app-server-process-teardown.test.ts b/src/main/provider-process/provider-process-teardown.test.ts similarity index 90% rename from src/main/codex/codex-app-server-process-teardown.test.ts rename to src/main/provider-process/provider-process-teardown.test.ts index 295d780718d0..4c7d1dae86d2 100644 --- a/src/main/codex/codex-app-server-process-teardown.test.ts +++ b/src/main/provider-process/provider-process-teardown.test.ts @@ -4,7 +4,7 @@ import { findSelfInitiatedTreeKills, resetSelfInitiatedTreeKillLogForTest } from '../crash-reporting/self-initiated-tree-kill-log' -import { terminateCodexAppServerProcessTree } from './codex-app-server-process-teardown' +import { terminateProviderProcessTree } from './provider-process-teardown' /** Above pid_max on every supported POSIX host, so the group signal is a real ESRCH. */ const UNREACHABLE_PGID = 2_147_483_647 @@ -16,7 +16,7 @@ function child() { } } -describe('terminateCodexAppServerProcessTree', () => { +describe('terminateProviderProcessTree', () => { beforeEach(() => { resetSelfInitiatedTreeKillLogForTest() }) @@ -26,7 +26,7 @@ describe('terminateCodexAppServerProcessTree', () => { const release = Promise.withResolvers() const terminateWindowsTree = vi.fn(() => release.promise) - const teardown = terminateCodexAppServerProcessTree(target, { + const teardown = terminateProviderProcessTree(target, { platform: 'win32', terminateWindowsTree }) @@ -43,7 +43,7 @@ describe('terminateCodexAppServerProcessTree', () => { const snapshot = { rootPgid: 1234, descendants: [], capturedAtMs: 1 } const release = Promise.withResolvers() - const teardown = terminateCodexAppServerProcessTree(target, { + const teardown = terminateProviderProcessTree(target, { platform: 'darwin', captureDescendants: async () => snapshot, terminateDescendants: () => release.promise @@ -62,7 +62,7 @@ describe('terminateCodexAppServerProcessTree', () => { const signalProcessGroup = vi.fn() await expect( - terminateCodexAppServerProcessTree(target, { + terminateProviderProcessTree(target, { platform: 'darwin', dedicatedProcessGroup: true, captureDescendants, @@ -79,7 +79,7 @@ describe('terminateCodexAppServerProcessTree', () => { const target = child() await expect( - terminateCodexAppServerProcessTree(target, { + terminateProviderProcessTree(target, { platform: 'linux', dedicatedProcessGroup: true, signalProcessGroup: () => { @@ -102,7 +102,7 @@ describe('terminateCodexAppServerProcessTree', () => { const target = { pid: UNREACHABLE_PGID, kill: vi.fn(() => true) as ChildProcess['kill'] } await expect( - terminateCodexAppServerProcessTree(target, { + terminateProviderProcessTree(target, { platform: 'darwin', captureDescendants: async () => ({ rootPgid: UNREACHABLE_PGID, @@ -122,7 +122,7 @@ describe('terminateCodexAppServerProcessTree', () => { const signalProcessGroup = vi.fn() await expect( - terminateCodexAppServerProcessTree(target, { + terminateProviderProcessTree(target, { platform: 'darwin', captureDescendants: async () => ({ rootPgid: 1234, descendants: [], capturedAtMs: 1 }), terminateDescendants: async () => true, @@ -151,7 +151,7 @@ describe('terminateCodexAppServerProcessTree', () => { const results = await Promise.all( targets.map((target) => - terminateCodexAppServerProcessTree(target, { + terminateProviderProcessTree(target, { platform: 'linux', dedicatedProcessGroup: true, captureDescendants, diff --git a/src/main/codex/codex-app-server-process-teardown.ts b/src/main/provider-process/provider-process-teardown.ts similarity index 90% rename from src/main/codex/codex-app-server-process-teardown.ts rename to src/main/provider-process/provider-process-teardown.ts index 10e625bef7e8..c55531f9be0f 100644 --- a/src/main/codex/codex-app-server-process-teardown.ts +++ b/src/main/provider-process/provider-process-teardown.ts @@ -8,7 +8,7 @@ const activeTeardowns = new WeakMap>() type TeardownChild = Pick -export type CodexAppServerProcessTeardownDeps = { +export type ProviderProcessTeardownDeps = { platform?: NodeJS.Platform dedicatedProcessGroup?: boolean captureDescendants?: (rootPid: number) => Promise @@ -17,10 +17,7 @@ export type CodexAppServerProcessTeardownDeps = { signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void } -function terminateDedicatedPosixGroup( - rootPid: number, - deps: CodexAppServerProcessTeardownDeps -): boolean { +function terminateDedicatedPosixGroup(rootPid: number, deps: ProviderProcessTeardownDeps): boolean { const signalGroup = deps.signalProcessGroup ?? ((pgid: number, signal: NodeJS.Signals) => process.kill(-pgid, signal)) @@ -41,7 +38,7 @@ function terminateDedicatedPosixGroup( async function terminatePosixTree( child: TeardownChild, rootPid: number, - deps: CodexAppServerProcessTeardownDeps + deps: ProviderProcessTeardownDeps ): Promise { child.kill('SIGSTOP') const capture = deps.captureDescendants ?? captureDescendantSnapshot @@ -87,10 +84,10 @@ async function terminatePosixTree( return true } -/** Stops every process owned by one app-server launch before releasing its wrapper. */ +/** Stops every process owned by one provider launch before releasing its wrapper. */ async function terminateOnce( child: TeardownChild, - deps: CodexAppServerProcessTeardownDeps + deps: ProviderProcessTeardownDeps ): Promise { const rootPid = child.pid if (!rootPid) { @@ -110,9 +107,9 @@ async function terminateOnce( return terminatePosixTree(child, rootPid, deps) } -export function terminateCodexAppServerProcessTree( +export function terminateProviderProcessTree( child: TeardownChild, - deps: CodexAppServerProcessTeardownDeps = {} + deps: ProviderProcessTeardownDeps = {} ): Promise { const key = child as object const active = activeTeardowns.get(key) diff --git a/src/main/codex/codex-app-server-record-reader.ts b/src/main/provider-process/provider-record-reader.ts similarity index 90% rename from src/main/codex/codex-app-server-record-reader.ts rename to src/main/provider-process/provider-record-reader.ts index bdaea79164b0..4c3608bc9994 100644 --- a/src/main/codex/codex-app-server-record-reader.ts +++ b/src/main/provider-process/provider-record-reader.ts @@ -6,17 +6,17 @@ import { type RecordReaderStream = Pick -export type CodexAppServerRecordReader = { +export type ProviderRecordReader = { pause: () => void resume: () => void } -export function createCodexAppServerRecordReader(input: { +export function createProviderRecordReader(input: { stdout: RecordReaderStream onRecord: (record: unknown, line: string) => void onRejected: (rejected: NdjsonRejectedRecord) => void onFatal: (error: Error) => void -}): CodexAppServerRecordReader { +}): ProviderRecordReader { let paused = false const framer = createIncrementalNdjsonFramer(input.onRecord, input.onRejected, { // The provider owns this local stdio stream, so valid agent payloads keep full fidelity. diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index 2e086367d8b5..69f28aa8d7e8 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -14,7 +14,7 @@ # `detached: true` for a process-group kill, a synchronous taskkill, and an stdio shape other # than all-pipes (a long-lived `ssh -N` must not be handed a stdin pipe nobody closes). # Migrating them means teaching run-process.ts those options first. -# codex-app-server-posix-supervisor.ts requires child_process only inside its +# provider-process-supervisor.ts requires child_process only inside its # dependency-free POSIX supervisor script, never in Orca's process. # # The src/main/git/command-runner/* entries are the seven spawn sites that used @@ -56,7 +56,7 @@ src/main/codex-accounts/legacy-wsl-runtime-auth-drain-recovery-script-harness.ts src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.ts src/main/codex-accounts/service.ts -src/main/codex/codex-app-server-posix-supervisor.ts +src/main/provider-process/provider-process-supervisor.ts src/main/codex/codex-app-server-session.ts src/main/codex/codex-wsl-hook-install-plan.ts src/main/computer/desktop-script-provider-bridge.ts From ef2bb104a23eef29d3e6d46d66bf925f26a5ea97 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:54:19 -0700 Subject: [PATCH 020/269] Preserve teardown behavior with checked mock types after move --- .../provider-process/provider-process-teardown.test.ts | 8 ++++---- src/main/provider-process/provider-process-teardown.ts | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/provider-process/provider-process-teardown.test.ts b/src/main/provider-process/provider-process-teardown.test.ts index 4c7d1dae86d2..60826a269c5f 100644 --- a/src/main/provider-process/provider-process-teardown.test.ts +++ b/src/main/provider-process/provider-process-teardown.test.ts @@ -12,7 +12,7 @@ const UNREACHABLE_PGID = 2_147_483_647 function child() { return { pid: 1234, - kill: vi.fn(() => true) as ChildProcess['kill'] + kill: vi.fn(() => true) } } @@ -99,7 +99,7 @@ describe('terminateProviderProcessTree', () => { * lives in the production default, not in an injectable seam. */ it('does not claim a snapshot group that was already gone', async () => { - const target = { pid: UNREACHABLE_PGID, kill: vi.fn(() => true) as ChildProcess['kill'] } + const target = { pid: UNREACHABLE_PGID, kill: vi.fn(() => true) } await expect( terminateProviderProcessTree(target, { @@ -141,10 +141,10 @@ describe('terminateProviderProcessTree', () => { }) it('tears down 40 dedicated groups without process-table scans or cross-group fanout', async () => { - const killMocks = Array.from({ length: 40 }, () => vi.fn(() => true)) + const killMocks = Array.from({ length: 40 }, () => vi.fn(() => true)) const targets = killMocks.map((kill, index) => ({ pid: 10_000 + index, - kill: kill as ChildProcess['kill'] + kill })) const captureDescendants = vi.fn() const signalProcessGroup = vi.fn() diff --git a/src/main/provider-process/provider-process-teardown.ts b/src/main/provider-process/provider-process-teardown.ts index c55531f9be0f..de55788ae5f2 100644 --- a/src/main/provider-process/provider-process-teardown.ts +++ b/src/main/provider-process/provider-process-teardown.ts @@ -24,6 +24,7 @@ function terminateDedicatedPosixGroup(rootPid: number, deps: ProviderProcessTear try { signalGroup(rootPid, 'SIGKILL') } catch (error) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Node process.kill errors expose an optional errno code; only that field is read. return (error as NodeJS.ErrnoException).code === 'ESRCH' } // Outside the try: that catch is the ESRCH contract, not a breadcrumb handler. @@ -111,7 +112,7 @@ export function terminateProviderProcessTree( child: TeardownChild, deps: ProviderProcessTeardownDeps = {} ): Promise { - const key = child as object + const key = child const active = activeTeardowns.get(key) if (active) { return active From f27344a5220c6d3e92d84eca8a60984a984653ec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:58:26 -0700 Subject: [PATCH 021/269] Add standalone ACP protocol client and session runtime --- config/scripts/acp/generate-protocol.mjs | 298 +++++++++++++ src/main/acp/acp-errors.ts | 31 ++ src/main/acp/acp-incoming-requests.ts | 91 ++++ src/main/acp/acp-json-rpc-peer.test.ts | 253 +++++++++++ src/main/acp/acp-json-rpc-peer.ts | 258 +++++++++++ src/main/acp/acp-permission-requests.ts | 88 ++++ .../acp/acp-scripted-agent.test-support.ts | 85 ++++ src/main/acp/acp-session-runtime.test.ts | 413 ++++++++++++++++++ src/main/acp/acp-session-runtime.ts | 303 +++++++++++++ src/main/acp/acp-session-setup.ts | 75 ++++ src/main/acp/acp-write-queue.ts | 119 +++++ .../acp/generated/client-capabilities.gen.ts | 113 +++++ src/main/acp/generated/diff.gen.ts | 126 ++++++ src/main/acp/generated/license.gen.ts | 197 +++++++++ src/main/acp/generated/mcp-server-acp.gen.ts | 116 +++++ .../generated/nes-context-capabilities.gen.ts | 124 ++++++ src/main/acp/generated/plan-update.gen.ts | 103 +++++ .../acp/generated/protocol-version.gen.ts | 93 ++++ src/main/acp/generated/protocol.gen.ts | 14 + .../generated/providers-capabilities.gen.ts | 87 ++++ .../generated/session-config-option.gen.ts | 113 +++++ src/main/acp/generated/session-update.gen.ts | 132 ++++++ .../set-session-model-request.gen.ts | 121 +++++ .../generated/text-resource-contents.gen.ts | 129 ++++++ .../wait-for-terminal-exit-request.gen.ts | 30 ++ 25 files changed, 3512 insertions(+) create mode 100644 config/scripts/acp/generate-protocol.mjs create mode 100644 src/main/acp/acp-errors.ts create mode 100644 src/main/acp/acp-incoming-requests.ts create mode 100644 src/main/acp/acp-json-rpc-peer.test.ts create mode 100644 src/main/acp/acp-json-rpc-peer.ts create mode 100644 src/main/acp/acp-permission-requests.ts create mode 100644 src/main/acp/acp-scripted-agent.test-support.ts create mode 100644 src/main/acp/acp-session-runtime.test.ts create mode 100644 src/main/acp/acp-session-runtime.ts create mode 100644 src/main/acp/acp-session-setup.ts create mode 100644 src/main/acp/acp-write-queue.ts create mode 100644 src/main/acp/generated/client-capabilities.gen.ts create mode 100644 src/main/acp/generated/diff.gen.ts create mode 100644 src/main/acp/generated/license.gen.ts create mode 100644 src/main/acp/generated/mcp-server-acp.gen.ts create mode 100644 src/main/acp/generated/nes-context-capabilities.gen.ts create mode 100644 src/main/acp/generated/plan-update.gen.ts create mode 100644 src/main/acp/generated/protocol-version.gen.ts create mode 100644 src/main/acp/generated/protocol.gen.ts create mode 100644 src/main/acp/generated/providers-capabilities.gen.ts create mode 100644 src/main/acp/generated/session-config-option.gen.ts create mode 100644 src/main/acp/generated/session-update.gen.ts create mode 100644 src/main/acp/generated/set-session-model-request.gen.ts create mode 100644 src/main/acp/generated/text-resource-contents.gen.ts create mode 100644 src/main/acp/generated/wait-for-terminal-exit-request.gen.ts diff --git a/config/scripts/acp/generate-protocol.mjs b/config/scripts/acp/generate-protocol.mjs new file mode 100644 index 000000000000..db3de9cf5e31 --- /dev/null +++ b/config/scripts/acp/generate-protocol.mjs @@ -0,0 +1,298 @@ +import { createHash } from 'node:crypto' +import { mkdir, readdir, readFile, unlink, writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' + +const release = 'schema-v1.21.0' +const legacyRelease = 'v0.11.6' +const repository = 'https://github.com/agentclientprotocol/agent-client-protocol' +const output = fileURLToPath(new URL('../../../src/main/acp/generated/', import.meta.url)) +const check = process.argv.includes('--check') +const definitions = {} +const header = `// Generated by config/scripts/acp/generate-protocol.mjs; do not edit.\n// ACP ${release}, legacy model API ${legacyRelease}; SPDX-License-Identifier: Apache-2.0.\n` + +async function download(url, digest) { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Download failed: ${url} (${response.status})`) + } + const text = await response.text() + if (createHash('sha256').update(text).digest('hex') !== digest) { + throw new Error(`Upstream content changed: ${url}`) + } + return text +} + +const [schema, legacy, license] = await Promise.all([ + download( + `${repository}/releases/download/${release}/schema.unstable.json`, + '7f77702b34e0a0558e77220e9007bf8ee161a976bb8ac5021aba1b7e7b2c5708' + ), + download( + `${repository}/releases/download/${legacyRelease}/schema.unstable.json`, + 'b3cf8687d979c98c009f0fbcf8f0c237645b82ac2d2f0a3ebca683f963c3d581' + ), + download( + `https://raw.githubusercontent.com/agentclientprotocol/agent-client-protocol/${release}/LICENSE`, + 'f250d08cee4549b22b3b4aaaf3a743473336fd280316df5d0340717e5127a221' + ) +]) +Object.assign(definitions, JSON.parse(legacy).$defs, JSON.parse(schema).$defs) + +const roots = [ + 'InitializeRequest', + 'InitializeResponse', + 'AuthenticateRequest', + 'AuthenticateResponse', + 'NewSessionRequest', + 'NewSessionResponse', + 'LoadSessionRequest', + 'LoadSessionResponse', + 'ResumeSessionRequest', + 'ResumeSessionResponse', + 'PromptRequest', + 'PromptResponse', + 'CancelNotification', + 'SessionNotification', + 'RequestPermissionRequest', + 'RequestPermissionResponse', + 'SetSessionModeRequest', + 'SetSessionModeResponse', + 'SetSessionModelRequest', + 'SetSessionModelResponse', + 'SetSessionConfigOptionRequest', + 'SetSessionConfigOptionResponse', + 'ReadTextFileRequest', + 'ReadTextFileResponse', + 'WriteTextFileRequest', + 'WriteTextFileResponse', + 'CreateTerminalRequest', + 'CreateTerminalResponse', + 'TerminalOutputRequest', + 'TerminalOutputResponse', + 'ReleaseTerminalRequest', + 'ReleaseTerminalResponse', + 'WaitForTerminalExitRequest', + 'WaitForTerminalExitResponse', + 'KillTerminalRequest', + 'KillTerminalResponse' +] + +function references(value) { + if (!value || typeof value !== 'object') { + return [] + } + if (Array.isArray(value)) { + return value.flatMap(references) + } + return [ + ...(value.$ref ? [value.$ref.split('/').at(-1)] : []), + ...Object.values(value).flatMap(references) + ] +} + +const ordered = [] +const visiting = new Set() +const visited = new Set() +function visit(name) { + if (visited.has(name)) { + return + } + if (visiting.has(name)) { + throw new Error(`Recursive schema needs an explicit type: ${name}`) + } + if (!definitions[name]) { + throw new Error(`Missing definition: ${name}`) + } + visiting.add(name) + for (const dependency of references(definitions[name])) { + visit(dependency) + } + visiting.delete(name) + visited.add(name) + ordered.push(name) +} +roots.forEach(visit) + +function expression(value) { + if (value === true) { + return 'z.unknown()' + } + if (value === false) { + return 'z.never()' + } + if (value.$ref) { + return `${value.$ref.split('/').at(-1)}Schema` + } + if ('const' in value) { + return `z.literal(${JSON.stringify(value.const)})` + } + const alternatives = value.oneOf ?? value.anyOf + if (alternatives || value.allOf) { + const combined = alternatives + ? `z.union([${alternatives.map(expression).join(',')}])` + : value.allOf.map(expression).reduce((left, right) => `z.intersection(${left},${right})`) + const siblings = { ...value } + delete siblings.oneOf + delete siblings.anyOf + delete siblings.allOf + return siblings.type || siblings.properties + ? `z.intersection(${expression(siblings)},${combined})` + : combined + } + if (Array.isArray(value.type)) { + return `z.union([${value.type.map((type) => expression({ ...value, type })).join(',')}])` + } + let result + switch (value.type) { + case 'string': + result = 'z.string()' + break + case 'integer': + result = 'z.number().int()' + break + case 'number': + result = 'z.number()' + break + case 'boolean': + result = 'z.boolean()' + break + case 'null': + result = 'z.null()' + break + case 'array': + result = `z.array(${expression(value.items ?? true)})` + break + case 'object': { + const properties = Object.entries(value.properties ?? {}).map( + ([key, property]) => + `${JSON.stringify(key)}:${expression(property)}${value.required?.includes(key) ? '' : '.optional()'}` + ) + result = `z.${value.additionalProperties === false ? 'strictObject' : 'looseObject'}({${properties.join(',')}})` + if (typeof value.additionalProperties === 'object') { + result += `.catchall(${expression(value.additionalProperties)})` + } + break + } + default: + if ( + Object.keys(value).some( + (key) => !key.startsWith('x-') && !['description', 'title', 'default'].includes(key) + ) + ) { + throw new Error(`Unsupported schema: ${JSON.stringify(value)}`) + } + result = 'z.unknown()' + } + if (['integer', 'number'].includes(value.type) && typeof value.minimum === 'number') { + result += `.min(${value.minimum})` + } + if (['integer', 'number'].includes(value.type) && typeof value.maximum === 'number') { + result += `.max(${value.maximum})` + } + if (value.not) { + result += `.refine(value=>!${expression(value.not)}.safeParse(value).success)` + } + return result +} + +// Pack in dependency order so generated imports stay acyclic and each module stays small. +const groups = [] +let group = [] +let length = 0 +for (const name of ordered) { + const body = `export const ${name}Schema = ${expression(definitions[name])}\nexport type ${name} = z.infer\n` + if (length + body.length > 4300 && group.length) { + groups.push(group) + group = [] + length = 0 + } + group.push({ name, body }) + length += body.length +} +if (group.length) { + groups.push(group) +} +const filenames = new Map() +for (const entries of groups) { + const filename = entries[0].name.replace( + /[A-Z]/g, + (c, offset) => `${offset ? '-' : ''}${c.toLowerCase()}` + ) + for (const { name } of entries) { + filenames.set(name, `${filename}.gen.ts`) + } +} + +const generated = new Map() +for (const entries of groups) { + const file = filenames.get(entries[0].name) + const imports = new Map() + for (const { body } of entries) { + for (const dependency of [...body.matchAll(/\b(\w+)Schema\b/g)].map((match) => match[1])) { + const source = filenames.get(dependency) + if (source === file) { + continue + } + if (!imports.has(source)) { + imports.set(source, new Set()) + } + imports.get(source).add(`${dependency}Schema`) + } + } + generated.set( + file, + `${header}import { z } from 'zod'\n${[...imports] + .map( + ([source, names]) => `import { ${[...names].join(',')} } from './${source.slice(0, -3)}'\n` + ) + .join('')}${entries.map((entry) => entry.body).join('\n')}` + ) +} +generated.set( + 'protocol.gen.ts', + `${ + header + + [...new Set(filenames.values())] + .map((file) => `export * from './${file.slice(0, -3)}'`) + .join('\n') + }\n` +) +generated.set( + 'license.gen.ts', + `${header}/*\n${license.trim()}\n*/\nexport const ACP_SCHEMA_RELEASE = '${release}'\nexport const ACP_LEGACY_MODEL_SCHEMA_RELEASE = '${legacyRelease}'\nexport const ACP_PROTOCOL_VERSION = 1\n` +) + +// Use the repository formatter without spawning a platform-dependent executable shim. +const { format } = await import('oxfmt') +await mkdir(output, { recursive: true }) +for (const [file, source] of generated) { + const formatted = await format(file, source, { + singleQuote: true, + semi: false, + printWidth: 100, + trailingComma: 'none' + }) + if (formatted.errors.length) { + throw new Error(`Formatting failed for ${file}`) + } + const destination = resolve(output, file) + if (check) { + if ((await readFile(destination, 'utf8')) !== formatted.code) { + throw new Error(`Stale generated file: ${file}`) + } + } else { + await writeFile(destination, formatted.code) + } +} +for (const file of await readdir(output)) { + if (file.endsWith('.gen.ts') && !generated.has(file)) { + if (check || !(await readFile(resolve(output, file), 'utf8')).startsWith(header)) { + throw new Error(`Unexpected generated file: ${file}`) + } + await unlink(resolve(output, file)) + } +} +console.log( + `${check ? 'Checked' : 'Generated'} ${ordered.length} ACP definitions in ${generated.size} files` +) diff --git a/src/main/acp/acp-errors.ts b/src/main/acp/acp-errors.ts new file mode 100644 index 000000000000..732db5c26cfc --- /dev/null +++ b/src/main/acp/acp-errors.ts @@ -0,0 +1,31 @@ +export class AcpRpcError extends Error { + constructor( + readonly code: number, + message: string, + readonly data?: unknown + ) { + super(message) + this.name = 'AcpRpcError' + } +} + +export class AcpAuthRequiredError extends AcpRpcError { + constructor(message: string, data?: unknown) { + super(-32000, message, data) + this.name = 'AcpAuthRequiredError' + } +} + +export class AcpConnectionClosedError extends Error { + constructor(message = 'ACP connection closed') { + super(message) + this.name = 'AcpConnectionClosedError' + } +} + +export class AcpRequestTimeoutError extends Error { + constructor(readonly method: string) { + super(`ACP request timed out: ${method}`) + this.name = 'AcpRequestTimeoutError' + } +} diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts new file mode 100644 index 000000000000..5b55fb8d5835 --- /dev/null +++ b/src/main/acp/acp-incoming-requests.ts @@ -0,0 +1,91 @@ +import { AcpRpcError } from './acp-errors' +import type { AcpPeerHandlers } from './acp-json-rpc-peer' + +type OpenRequest = { controller: AbortController; abandon: () => void } + +export class AcpIncomingRequests { + private readonly open = new Map() + + constructor( + private readonly handler: AcpPeerHandlers['onRequest'], + private readonly send: (message: object) => Promise, + private readonly onFailure: (error: Error) => void, + private readonly capacity: number, + private readonly timeoutMs: number + ) {} + + close(error: Error): void { + for (const request of this.open.values()) { + request.controller.abort(error) + request.abandon() + } + this.open.clear() + } + + handle(id: string | number | null, method: string, params: unknown): void { + if (this.open.has(id) || this.open.size >= this.capacity) { + void this.sendError( + id, + new AcpRpcError(-32600, 'Duplicate request id or incoming request capacity exceeded') + ) + return + } + const controller = new AbortController() + let timer: ReturnType | undefined + let abandon = (): void => {} + const deadline = new Promise((_resolve, reject) => { + abandon = () => reject(controller.signal.reason) + timer = setTimeout( + () => reject(new AcpRpcError(-32001, `ACP client request timed out: ${method}`)), + this.timeoutMs + ) + }) + this.open.set(id, { controller, abandon }) + void Promise.race([ + deadline, + Promise.resolve().then(() => { + if (controller.signal.aborted) { + return undefined + } + return this.handler?.(method, params, { id, signal: controller.signal }) + }) + ]) + .then(async (result) => { + if (controller.signal.aborted) { + return + } + if (result === undefined) { + throw new AcpRpcError(-32601, `Unknown ACP client method: ${method}`) + } + await this.send({ jsonrpc: '2.0', id, result }) + }) + .catch(async (error) => { + if (controller.signal.aborted) { + return + } + await this.sendError( + id, + error instanceof AcpRpcError + ? error + : new AcpRpcError(-32603, error instanceof Error ? error.message : String(error)) + ) + }) + .finally(() => { + clearTimeout(timer) + this.open.delete(id) + controller.abort() + }) + } + + private async sendError(id: string | number | null, error: AcpRpcError): Promise { + try { + await this.send({ + jsonrpc: '2.0', + id, + error: { code: error.code, message: error.message, data: error.data } + }) + } catch (failure) { + this.onFailure(failure instanceof Error ? failure : new Error(String(failure))) + } + } +} diff --git a/src/main/acp/acp-json-rpc-peer.test.ts b/src/main/acp/acp-json-rpc-peer.test.ts new file mode 100644 index 000000000000..3aba7848293a --- /dev/null +++ b/src/main/acp/acp-json-rpc-peer.test.ts @@ -0,0 +1,253 @@ +import { PassThrough, Writable } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AcpJsonRpcPeer, type AcpPeerHandlers, type AcpPeerOptions } from './acp-json-rpc-peer' +import { AcpConnectionClosedError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' +import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' + +const peers: AcpJsonRpcPeer[] = [] +const agents: AcpScriptedAgent[] = [] +function fixture(handlers: AcpPeerHandlers = {}, options: AcpPeerOptions = {}) { + const agent = new AcpScriptedAgent() + const peer = new AcpJsonRpcPeer(agent.stdout, agent.stdin, handlers, options) + agents.push(agent) + peers.push(peer) + return { peer, agent } +} +afterEach(() => { + peers.splice(0).forEach((peer) => peer.close()) + agents.splice(0).forEach((agent) => agent.close()) + vi.useRealTimers() +}) + +describe('ACP JSON-RPC peer', () => { + it('routes interleaved requests in both directions without conflating id types', async () => { + const { peer, agent } = fixture({ onRequest: (method, params) => ({ method, params }) }) + const first = peer.request('first', {}) + const second = peer.request('second', {}) + await tick() + expect(agent.frames.map((frame) => frame.id)).toEqual([1, 2]) + agent.send({ jsonrpc: '2.0', id: '1', result: 'not the numeric id' }) + const fromAgent = agent.request(1, '_question', { text: 'hello' }) + agent.reply(agent.frames[1], 'second result') + agent.reply(agent.frames[0], 'first result') + expect(await second).toBe('second result') + expect(await first).toBe('first result') + expect(await fromAgent).toMatchObject({ + id: 1, + result: { method: '_question', params: { text: 'hello' } } + }) + expect(await agent.request(null, '_null_id', {})).toMatchObject({ + id: null, + result: { method: '_null_id' } + }) + }) + + it('returns method-not-found and preserves explicit handler error objects', async () => { + const { agent } = fixture({ + onRequest: (method) => { + if (method === '_denied') { + throw new AcpRpcError(-32005, 'Denied', { reason: 'policy' }) + } + if (method === '_crash') { + throw new Error('Handler failed') + } + return undefined + } + }) + expect(await agent.request('missing', '_missing', {})).toMatchObject({ + error: { code: -32601 } + }) + expect(await agent.request('denied', '_denied', {})).toMatchObject({ + error: { code: -32005, message: 'Denied', data: { reason: 'policy' } } + }) + expect(await agent.request('crash', '_crash', {})).toMatchObject({ + error: { code: -32603, message: 'Handler failed' } + }) + }) + + it('ignores malformed and oversized lines then resumes at the next newline', async () => { + const diagnostics: string[] = [] + const notified: unknown[] = [] + const { peer, agent } = fixture( + { + onDiagnostic: (message) => diagnostics.push(message), + onNotification: (_method, params) => notified.push(params) + }, + { maxLineBytes: 100 } + ) + agent.stdout.write('not json\n[]\n{"jsonrpc":"1.0","method":"bad"}\n') + for (let i = 0; i < 20; i++) { + agent.stdout.write('x'.repeat(40)) + } + agent.stdout.write('\n') + const data = Buffer.from('{"jsonrpc":"2.0","method":"notice","params":"✓"}\r\n') + const split = data.indexOf(Buffer.from('✓')) + 1 + agent.stdout.write(data.subarray(0, split)) + agent.stdout.write(data.subarray(split)) + agent.on('valid', (frame) => agent.reply(frame, 'ok')) + expect(await peer.request('valid', {})).toBe('ok') + expect(notified).toEqual(['✓']) + expect(diagnostics).toContain('Ignored ACP line: invalid-json') + expect(diagnostics).toContain('Ignored ACP line: line-too-long') + expect(diagnostics).toContain('Ignored invalid ACP JSON-RPC envelope') + }) + + it('ignores malformed response envelopes without settling pending requests', async () => { + const { peer, agent } = fixture() + const pending = peer.request('wait', {}) + agent.stdout.write('{"jsonrpc":"2.0","id":1}\n') + agent.stdout.write('{"jsonrpc":"2.0","id":1,"result":"x","error":{"code":1,"message":"y"}}\n') + agent.stdout.write('{"jsonrpc":"2.0","id":1,"result":"ok"}\n') + expect(await pending).toBe('ok') + }) + + it('rejects all pending calls on exit and stops accepting requests', async () => { + const onClose = vi.fn() + const { peer, agent } = fixture({ onClose }) + const first = expect(peer.request('first', {})).rejects.toBeInstanceOf(AcpConnectionClosedError) + const second = expect(peer.request('second', {})).rejects.toBeInstanceOf( + AcpConnectionClosedError + ) + agent.stdout.end() + await Promise.all([first, second]) + peer.close() + expect(onClose).toHaveBeenCalledTimes(1) + expect(agent.stdout.listenerCount('data')).toBe(0) + await expect(peer.request('late', {})).rejects.toBeInstanceOf(AcpConnectionClosedError) + }) + + it('bounds pending calls and frees capacity when a request times out', async () => { + vi.useFakeTimers() + const { peer, agent } = fixture({}, { maxPendingRequests: 1, requestTimeoutMs: 20 }) + const timedOut = expect(peer.request('wait', {})).rejects.toBeInstanceOf(AcpRequestTimeoutError) + await expect(peer.request('overflow', {})).rejects.toThrow('capacity exceeded') + await vi.advanceTimersByTimeAsync(20) + await timedOut + agent.on('next', (frame) => agent.reply(frame, 'ok')) + expect(await peer.request('next', {})).toBe('ok') + expect(agent.frames.map((frame) => frame.method)).toEqual(['wait', 'next']) + }) + + it('expires unanswered incoming requests and aborts their hooks', async () => { + vi.useFakeTimers() + const signals: AbortSignal[] = [] + const { agent } = fixture( + { + onRequest: (_method, _params, context) => { + signals.push(context.signal) + return new Promise(() => {}) + } + }, + { maxIncomingRequests: 1, incomingRequestTimeoutMs: 20 } + ) + const first = agent.request('first', '_question', {}) + await vi.advanceTimersByTimeAsync(0) + expect(await agent.request('overflow', '_question', {})).toMatchObject({ + error: { code: -32600 } + }) + await vi.advanceTimersByTimeAsync(20) + expect(await first).toMatchObject({ error: { code: -32001 } }) + expect(signals[0].aborted).toBe(true) + const next = agent.request('next', '_question', {}) + await vi.advanceTimersByTimeAsync(20) + expect(await next).toMatchObject({ error: { code: -32001 } }) + expect(signals).toHaveLength(2) + }) + + it('aborts an in-flight incoming hook on close and never writes its late response', async () => { + const entered = deferred() + const result = deferred() + const { peer, agent } = fixture({ + onRequest: (_method, _params, context) => { + entered.resolve(context.signal) + return result.promise + } + }) + void agent.request('open', '_question', {}) + const signal = await entered.promise + peer.close() + expect(signal.aborted).toBe(true) + result.resolve({ late: true }) + await tick() + expect(agent.frames).toEqual([]) + }) + + it('serializes writes through backpressure and bounds queued output', async () => { + const writes: string[] = [] + const callbacks: ((error?: Error | null) => void)[] = [] + const output = new Writable({ + highWaterMark: 1, + write(chunk, _encoding, callback) { + writes.push(chunk.toString()) + callbacks.push(callback) + } + }) + const input = new PassThrough() + const peer = new AcpJsonRpcPeer(input, output, {}, { maxQueuedWriteBytes: 170 }) + peers.push(peer) + const first = peer.notify('one', {}) + const second = peer.notify('two', {}) + const third = peer.notify('three', {}) + await expect(peer.notify('overflow', {})).rejects.toThrow('capacity exceeded') + expect(writes).toHaveLength(1) + callbacks[0]() + await first + expect(writes).toHaveLength(2) + callbacks[1]() + await second + callbacks[2]() + await third + expect(writes.map((line) => JSON.parse(line).method)).toEqual(['one', 'two', 'three']) + peer.close() + input.destroy() + output.destroy() + }) + + it('rejects active and queued writes when output closes', async () => { + const input = new PassThrough() + const output = new Writable({ write(_chunk, _encoding, _callback) {} }) + const peer = new AcpJsonRpcPeer(input, output) + peers.push(peer) + const first = expect(peer.notify('one', {})).rejects.toBeInstanceOf(AcpConnectionClosedError) + const second = expect(peer.request('two', {})).rejects.toBeInstanceOf(AcpConnectionClosedError) + output.destroy() + await Promise.all([first, second]) + input.destroy() + }) + + it('removes a timed-out queued request before it can reach the agent', async () => { + vi.useFakeTimers() + const writes: string[] = [] + const callbacks: ((error?: Error | null) => void)[] = [] + const input = new PassThrough() + const output = new Writable({ + highWaterMark: 1, + write(chunk, _encoding, callback) { + writes.push(chunk.toString()) + callbacks.push(callback) + } + }) + const peer = new AcpJsonRpcPeer(input, output, {}, { requestTimeoutMs: 20 }) + peers.push(peer) + const first = peer.notify('hold', {}) + const timedOut = expect(peer.request('must-not-arrive', {})).rejects.toBeInstanceOf( + AcpRequestTimeoutError + ) + await vi.advanceTimersByTimeAsync(20) + await timedOut + callbacks[0]() + await first + expect(writes.map((line) => JSON.parse(line).method)).toEqual(['hold']) + peer.close() + input.destroy() + output.destroy() + }) + + it('fails pending requests on an output stream error', async () => { + const { peer, agent } = fixture() + const rejected = expect(peer.request('wait', {})).rejects.toThrow('Broken pipe') + agent.stdin.emit('error', new Error('Broken pipe')) + await rejected + expect(peer.closed).toBe(true) + }) +}) diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts new file mode 100644 index 000000000000..0924a1f797fa --- /dev/null +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -0,0 +1,258 @@ +import type { Readable, Writable } from 'node:stream' +import { z } from 'zod' +import { + createIncrementalNdjsonFramer, + encodeNdjson +} from '../../shared/main-process-ndjson-framer' +import { + AcpAuthRequiredError, + AcpConnectionClosedError, + AcpRequestTimeoutError, + AcpRpcError +} from './acp-errors' +import { AcpIncomingRequests } from './acp-incoming-requests' +import { AcpWriteQueue } from './acp-write-queue' + +const idSchema = z.union([z.string(), z.number(), z.null()]) +const envelopeSchema = z.looseObject({ + jsonrpc: z.literal('2.0'), + id: idSchema.optional(), + method: z.string().optional(), + params: z.unknown().optional(), + result: z.unknown().optional(), + error: z + .object({ code: z.number().int(), message: z.string(), data: z.unknown().optional() }) + .optional() +}) +export type AcpRequestContext = { id: string | number | null; signal: AbortSignal } +export type AcpPeerHandlers = { + onRequest?: ( + method: string, + params: unknown, + context: AcpRequestContext + ) => unknown | Promise + onNotification?: (method: string, params: unknown) => void + onDiagnostic?: (message: string) => void + onClose?: (error: Error) => void +} +export type AcpPeerOptions = { + maxLineBytes?: number + maxQueuedWriteBytes?: number + maxPendingRequests?: number + maxIncomingRequests?: number + requestTimeoutMs?: number + incomingRequestTimeoutMs?: number +} +type Pending = { + resolve: (value: unknown) => void + reject: (error: Error) => void + timer: ReturnType +} + +export class AcpJsonRpcPeer { + private readonly pending = new Map() + private readonly incoming: AcpIncomingRequests + private readonly writer: AcpWriteQueue + private readonly framer: ReturnType + private nextId = 1 + private terminalError?: Error + private readonly maxLineBytes: number + private readonly maxPending: number + private readonly maxIncoming: number + private readonly timeoutMs: number + + constructor( + private readonly input: Readable, + private readonly output: Writable, + private readonly handlers: AcpPeerHandlers = {}, + options: AcpPeerOptions = {} + ) { + this.maxLineBytes = bounded(options.maxLineBytes, 16 * 1024 * 1024) + this.maxPending = bounded(options.maxPendingRequests, 128) + this.maxIncoming = bounded(options.maxIncomingRequests, 128) + this.timeoutMs = bounded(options.requestTimeoutMs, 120_000) + this.writer = new AcpWriteQueue( + output, + bounded(options.maxQueuedWriteBytes, 32 * 1024 * 1024), + (error) => this.close(error) + ) + this.incoming = new AcpIncomingRequests( + handlers.onRequest, + (message) => this.send(message), + (error) => this.close(error), + this.maxIncoming, + bounded(options.incomingRequestTimeoutMs, this.timeoutMs) + ) + this.framer = createIncrementalNdjsonFramer( + (record) => this.dispatch(record), + (rejected) => this.diagnose(`Ignored ACP line: ${rejected.kind}`), + { maxLineBytes: this.maxLineBytes } + ) + input.setEncoding('utf8') + input.on('data', this.onData) + input.on('end', this.onEnd) + input.on('close', this.onEnd) + input.on('error', this.onError) + output.on('close', this.onEnd) + output.on('finish', this.onEnd) + output.on('error', this.onError) + if (input.destroyed || input.readableEnded || output.destroyed || !output.writable) { + this.onEnd() + } + } + + get closed(): boolean { + return this.terminalError !== undefined + } + + request(method: string, params: unknown, options: { timeoutMs?: number } = {}): Promise { + if (this.terminalError) { + return Promise.reject(this.terminalError) + } + if (this.pending.size >= this.maxPending) { + return Promise.reject(new Error('ACP pending request capacity exceeded')) + } + const id = this.nextId++ + const controller = new AbortController() + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => { + this.pending.delete(id) + const error = new AcpRequestTimeoutError(method) + controller.abort(error) + reject(error) + }, + bounded(options.timeoutMs, this.timeoutMs) + ) + this.pending.set(id, { resolve, reject, timer }) + void this.send({ jsonrpc: '2.0', id, method, params }, controller.signal).catch((error) => { + const pending = this.pending.get(id) + if (!pending) { + return + } + this.pending.delete(id) + clearTimeout(timer) + pending.reject(error instanceof Error ? error : new Error(String(error))) + }) + }) + } + + notify(method: string, params: unknown): Promise { + return this.send({ jsonrpc: '2.0', method, params }) + } + + close(error: Error = new AcpConnectionClosedError()): void { + if (this.terminalError) { + return + } + this.terminalError = error + this.input.removeListener('data', this.onData) + this.input.removeListener('end', this.onEnd) + this.input.removeListener('close', this.onEnd) + this.input.removeListener('error', this.onError) + this.output.removeListener('close', this.onEnd) + this.output.removeListener('finish', this.onEnd) + this.output.removeListener('error', this.onError) + this.framer.reset() + this.writer.close(error) + for (const pending of this.pending.values()) { + clearTimeout(pending.timer) + pending.reject(error) + } + this.pending.clear() + this.incoming.close(error) + try { + this.handlers.onClose?.(error) + } catch (failure) { + this.diagnose(String(failure)) + } + } + + private readonly onData = (chunk: string): void => { + try { + this.framer.feed(chunk) + } catch (error) { + this.close(error instanceof Error ? error : new Error(String(error))) + } + } + private readonly onEnd = (): void => this.close() + private readonly onError = (error: Error): void => this.close(error) + private diagnose(message: string): void { + try { + this.handlers.onDiagnostic?.(message) + } catch { + /* Diagnostics cannot break the transport. */ + } + } + + private send(message: object, signal?: AbortSignal): Promise { + if (this.terminalError) { + return Promise.reject(this.terminalError) + } + try { + return this.writer.write(encodeNdjson(message, this.maxLineBytes), signal) + } catch (error) { + return Promise.reject(error) + } + } + + private dispatch(record: unknown): void { + if (this.closed) { + return + } + const parsed = envelopeSchema.safeParse(record) + if (!parsed.success) { + this.diagnose('Ignored invalid ACP JSON-RPC envelope') + return + } + const frame = parsed.data + if (frame.method !== undefined) { + if ('result' in frame || 'error' in frame) { + this.diagnose('Ignored invalid ACP request') + return + } + if (frame.id !== undefined) { + this.incoming.handle(frame.id, frame.method, frame.params) + return + } + try { + this.handlers.onNotification?.(frame.method, frame.params) + } catch (error) { + this.diagnose(`ACP notification handler failed: ${String(error)}`) + } + return + } + if ('result' in frame === 'error' in frame) { + this.diagnose('Ignored invalid ACP response') + return + } + if (typeof frame.id !== 'number') { + return + } + const pending = this.pending.get(frame.id) + if (!pending) { + return + } + this.pending.delete(frame.id) + clearTimeout(pending.timer) + if (frame.error) { + pending.reject( + frame.error.code === -32000 + ? new AcpAuthRequiredError(frame.error.message, frame.error.data) + : new AcpRpcError(frame.error.code, frame.error.message, frame.error.data) + ) + } else { + pending.resolve(frame.result) + } + } +} + +function bounded(value: number | undefined, fallback: number): number { + if (value === undefined) { + return fallback + } + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error('ACP limits must be positive finite integers') + } + return value +} diff --git a/src/main/acp/acp-permission-requests.ts b/src/main/acp/acp-permission-requests.ts new file mode 100644 index 000000000000..120f896be2b6 --- /dev/null +++ b/src/main/acp/acp-permission-requests.ts @@ -0,0 +1,88 @@ +import { AcpRpcError } from './acp-errors' +import type { AcpRequestContext } from './acp-json-rpc-peer' +import { + RequestPermissionResponseSchema, + type RequestPermissionRequest, + type RequestPermissionResponse +} from './generated/protocol.gen' + +export type AcpPermissionHandler = ( + request: RequestPermissionRequest, + context: AcpRequestContext +) => RequestPermissionResponse | Promise + +const cancelled: RequestPermissionResponse = { outcome: { outcome: 'cancelled' } } +type OpenPermission = { sessionId: string; cancel: () => void } + +export class AcpPermissionRequests { + private readonly open = new Set() + + cancel(sessionId: string): void { + for (const permission of this.open) { + if (permission.sessionId === sessionId) { + permission.cancel() + } + } + } + + handle( + request: RequestPermissionRequest, + context: AcpRequestContext, + handler: AcpPermissionHandler | undefined + ): Promise { + if (!handler || context.signal.aborted) { + return Promise.resolve(cancelled) + } + return new Promise((resolve, reject) => { + const controller = new AbortController() + let settled = false + const finish = (response: RequestPermissionResponse | Error): void => { + if (settled) { + return + } + settled = true + this.open.delete(permission) + context.signal.removeEventListener('abort', permission.cancel) + controller.abort() + if (response instanceof Error) { + reject(response) + } else { + resolve(response) + } + } + const permission: OpenPermission = { + sessionId: request.sessionId, + cancel: () => finish(cancelled) + } + this.open.add(permission) + context.signal.addEventListener('abort', permission.cancel, { once: true }) + void Promise.resolve() + .then(() => { + if (controller.signal.aborted) { + return cancelled + } + return handler(request, { id: context.id, signal: controller.signal }) + }) + .then((response) => { + if (settled) { + return + } + const parsed = RequestPermissionResponseSchema.safeParse(response) + if (!parsed.success) { + finish(new AcpRpcError(-32603, 'Invalid permission handler response')) + return + } + const outcome = parsed.data.outcome + if ( + outcome.outcome === 'selected' && + !request.options.some((option) => option.optionId === outcome.optionId) + ) { + finish(new AcpRpcError(-32603, 'Permission handler selected an unavailable option')) + return + } + finish(parsed.data) + }) + .catch((error) => finish(error instanceof Error ? error : new Error(String(error)))) + }) + } +} diff --git a/src/main/acp/acp-scripted-agent.test-support.ts b/src/main/acp/acp-scripted-agent.test-support.ts new file mode 100644 index 000000000000..7de3fe93d2e6 --- /dev/null +++ b/src/main/acp/acp-scripted-agent.test-support.ts @@ -0,0 +1,85 @@ +import { PassThrough } from 'node:stream' +import { z } from 'zod' + +const frameSchema = z.object({ + jsonrpc: z.literal('2.0'), + id: z.union([z.string(), z.number(), z.null()]).optional(), + method: z.string().optional(), + params: z.unknown().optional(), + result: z.unknown().optional(), + error: z + .object({ code: z.number(), message: z.string(), data: z.unknown().optional() }) + .optional() +}) +export type FakeFrame = z.infer +type Handler = (frame: FakeFrame) => void + +export class AcpScriptedAgent { + readonly stdout = new PassThrough() + readonly stdin = new PassThrough() + readonly frames: FakeFrame[] = [] + private readonly methods = new Map() + private readonly requests = new Map void>() + private suffix = '' + + constructor() { + this.stdin.setEncoding('utf8').on('data', (chunk: string) => { + this.suffix += chunk + let newline: number + while ((newline = this.suffix.indexOf('\n')) !== -1) { + const frame = frameSchema.parse(JSON.parse(this.suffix.slice(0, newline))) + this.suffix = this.suffix.slice(newline + 1) + this.frames.push(frame) + if (frame.method !== undefined) { + this.methods.get(frame.method)?.(frame) + } else if (frame.id !== undefined) { + this.requests.get(frame.id)?.(frame) + this.requests.delete(frame.id) + } + } + }) + } + + on(method: string, handler: Handler): void { + this.methods.set(method, handler) + } + reply(frame: FakeFrame, result: unknown): void { + this.send({ jsonrpc: '2.0', id: frame.id, result }) + } + fail(frame: FakeFrame, code: number, message: string, data?: unknown): void { + this.send({ jsonrpc: '2.0', id: frame.id, error: { code, message, data } }) + } + notify(method: string, params: unknown): void { + this.send({ jsonrpc: '2.0', method, params }) + } + request(id: string | number | null, method: string, params: unknown): Promise { + return new Promise((resolve) => { + this.requests.set(id, resolve) + this.send({ jsonrpc: '2.0', id, method, params }) + }) + } + send(frame: FakeFrame): void { + this.stdout.write(`${JSON.stringify(frame)}\n`) + } + close(): void { + this.stdout.end() + this.stdin.end() + } +} + +export function deferred(): { promise: Promise; resolve: (value: T) => void } { + let fulfill: ((value: T) => void) | undefined + const promise = new Promise((resolve) => { + fulfill = resolve + }) + return { + promise, + resolve: (value) => { + fulfill?.(value) + } + } +} + +export function tick(): Promise { + return new Promise((resolve) => setImmediate(resolve)) +} diff --git a/src/main/acp/acp-session-runtime.test.ts b/src/main/acp/acp-session-runtime.test.ts new file mode 100644 index 000000000000..bdc1e0cd8256 --- /dev/null +++ b/src/main/acp/acp-session-runtime.test.ts @@ -0,0 +1,413 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AcpAuthRequiredError, + AcpConnectionClosedError, + AcpRequestTimeoutError +} from './acp-errors' +import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' +import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' +import type { + AgentCapabilities, + RequestPermissionResponse, + SessionNotification +} from './generated/protocol.gen' +import { SetSessionConfigOptionRequestSchema } from './generated/protocol.gen' + +const opened: { runtime: AcpSessionRuntime; agent: AcpScriptedAgent }[] = [] +const startOptions = { cwd: '/runtime/project', mcpServers: [] } +const textPrompt = [{ type: 'text', text: 'hello' }] as const +const permission = { + sessionId: 'session-1', + toolCall: { toolCallId: 'tool-1', title: 'Edit file' }, + options: [{ optionId: 'allow', name: 'Allow once', kind: 'allow_once' }] +} +function fixture(capabilities: AgentCapabilities = {}, options: AcpSessionRuntimeOptions = {}) { + const agent = new AcpScriptedAgent() + agent.on('initialize', (frame) => + agent.reply(frame, { protocolVersion: 1, agentCapabilities: capabilities }) + ) + agent.on('session/new', (frame) => agent.reply(frame, { sessionId: 'session-1' })) + agent.on('session/load', (frame) => agent.reply(frame, {})) + agent.on('session/resume', (frame) => agent.reply(frame, {})) + agent.on('session/prompt', (frame) => agent.reply(frame, { stopReason: 'end_turn' })) + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, options) + opened.push({ runtime, agent }) + return { runtime, agent } +} +afterEach(() => { + for (const { runtime, agent } of opened.splice(0)) { + runtime.close() + agent.close() + } + vi.useRealTimers() +}) + +describe('ACP session runtime', () => { + it('initializes once, starts a session, streams typed updates, and completes the turn', async () => { + const { runtime, agent } = fixture() + const events: SessionNotification[] = [] + const unsubscribe = runtime.subscribe((event) => events.push(event)) + const updates = [ + { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello' } }, + { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'Thinking' } }, + { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'hello' } }, + { sessionUpdate: 'tool_call', toolCallId: 'tool-1', title: 'Read file' }, + { sessionUpdate: 'tool_call_update', toolCallId: 'tool-1', status: 'completed' }, + { + sessionUpdate: 'plan', + entries: [{ content: 'Read', priority: 'medium', status: 'completed' }] + }, + { sessionUpdate: 'usage_update', used: 12, size: 100 }, + { sessionUpdate: 'available_commands_update', availableCommands: [] }, + { sessionUpdate: 'current_mode_update', currentModeId: 'plan' }, + { sessionUpdate: 'config_option_update', configOptions: [] }, + { sessionUpdate: 'session_info_update', title: 'Test session' } + ] + agent.on('session/prompt', (frame) => { + for (const update of updates) { + agent.notify('session/update', { sessionId: 'session-1', update }) + } + agent.reply(frame, { stopReason: 'end_turn' }) + }) + await Promise.all([runtime.initialize(), runtime.initialize()]) + expect(await runtime.start(startOptions)).toMatchObject({ kind: 'new', sessionId: 'session-1' }) + expect(await runtime.prompt([...textPrompt])).toEqual({ stopReason: 'end_turn' }) + expect(events.map((event) => event.update)).toEqual(updates) + expect(agent.frames.filter((frame) => frame.method === 'initialize')).toHaveLength(1) + expect(agent.frames[0].params).toEqual({ + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false } + }) + expect(agent.frames.find((frame) => frame.method === 'session/new')?.params).toEqual( + startOptions + ) + unsubscribe() + agent.notify('session/update', { sessionId: 'session-1', update: updates[0] }) + expect(events).toHaveLength(updates.length) + }) + + it.each([ + [{ loadSession: true }, undefined, 'load'], + [{ sessionCapabilities: { resume: {} } }, undefined, 'resume'], + [{ loadSession: true, sessionCapabilities: { resume: {} } }, undefined, 'load'], + [{ loadSession: true, sessionCapabilities: { resume: {} } }, 'resume', 'resume'], + [{ loadSession: true, sessionCapabilities: { resume: null } }, 'resume', 'load'] + ] as const)( + 'selects supported activation (%j, %s)', + async (capabilities, preference, expected) => { + const { runtime, agent } = fixture(capabilities) + const result = await runtime.start({ + ...startOptions, + sessionId: 'old', + resumePreference: preference + }) + expect(result).toMatchObject({ kind: expected, sessionId: 'old' }) + expect(agent.frames.at(-1)).toMatchObject({ + method: `session/${expected}`, + params: { ...startOptions, sessionId: 'old' } + }) + } + ) + + it('refuses unsupported restoration without silently creating a different session', async () => { + const { runtime, agent } = fixture({ sessionCapabilities: { resume: null } }) + await expect(runtime.start({ ...startOptions, sessionId: 'old' })).rejects.toMatchObject({ + code: -32601 + }) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize']) + }) + + it('round-trips permission and vendor requests while prompt is pending', async () => { + const permitted = deferred() + const { runtime, agent } = fixture( + {}, + { + onPermission: (request) => { + expect(request.toolCall.toolCallId).toBe('tool-1') + return { outcome: { outcome: 'selected', optionId: 'allow' } } + }, + onRequest: (method, params) => + method === '_vendor/question' ? { answer: params } : undefined + } + ) + agent.on('session/prompt', (frame) => { + void agent.request('agent-id', 'session/request_permission', permission).then((response) => { + permitted.resolve(response.result) + agent.reply(frame, { stopReason: 'end_turn' }) + }) + }) + await runtime.start(startOptions) + const prompt = runtime.prompt([...textPrompt]) + expect(await permitted.promise).toEqual({ outcome: { outcome: 'selected', optionId: 'allow' } }) + await prompt + expect(await agent.request(1, '_vendor/question', 'yes')).toMatchObject({ + id: 1, + result: { answer: 'yes' } + }) + expect(await agent.request('unknown', '_vendor/unknown', {})).toMatchObject({ + error: { code: -32601 } + }) + expect(await agent.request('fs', 'fs/read_text_file', {})).toMatchObject({ + error: { code: -32601 } + }) + }) + + it('cancels open and late permissions and waits for the agent cancellation response', async () => { + const requested = deferred() + const decision = deferred() + const responses: unknown[] = [] + const { runtime, agent } = fixture( + {}, + { + onPermission: (_request, context) => { + requested.resolve(context.signal) + return decision.promise + } + } + ) + agent.on('session/prompt', (frame) => { + void agent + .request('permission', 'session/request_permission', permission) + .then((response) => responses.push(response.result)) + agent.on('session/cancel', (cancel) => { + expect(cancel.id).toBeUndefined() + void agent + .request('late-permission', 'session/request_permission', permission) + .then((response) => { + responses.push(response.result) + agent.reply(frame, { stopReason: 'cancelled' }) + }) + }) + }) + await runtime.start(startOptions) + const prompt = runtime.prompt([...textPrompt]) + const signal = await requested.promise + const cancel = runtime.cancel() + expect(runtime.cancel()).toBe(cancel) + expect(await prompt).toEqual({ stopReason: 'cancelled' }) + await cancel + expect(signal.aborted).toBe(true) + expect(responses).toEqual([ + { outcome: { outcome: 'cancelled' } }, + { outcome: { outcome: 'cancelled' } } + ]) + decision.resolve({ outcome: { outcome: 'selected', optionId: 'allow' } }) + await tick() + expect(agent.frames.filter((frame) => frame.id === 'permission')).toHaveLength(1) + }) + + it('surfaces authentication-required errors with code and data', async () => { + const { runtime, agent } = fixture() + agent.on('session/new', (frame) => + agent.fail(frame, -32000, 'Login required', { detail: 'Sign in' }) + ) + await expect(runtime.start(startOptions)).rejects.toBeInstanceOf(AcpAuthRequiredError) + await expect(runtime.start(startOptions)).rejects.toMatchObject({ + code: -32000, + data: { detail: 'Sign in' } + }) + }) + + it('authenticates once with a configured agent method and retries session setup', async () => { + const { runtime, agent } = fixture() + let authenticated = false + agent.on('initialize', (frame) => + agent.reply(frame, { protocolVersion: 1, authMethods: [{ id: 'login', name: 'Login' }] }) + ) + agent.on('session/new', (frame) => + authenticated + ? agent.reply(frame, { sessionId: 'session-1' }) + : agent.fail(frame, -32000, 'Login required') + ) + agent.on('authenticate', (frame) => { + authenticated = true + agent.reply(frame, {}) + }) + await runtime.start({ ...startOptions, authMethodId: 'login' }) + expect(agent.frames.map((frame) => frame.method)).toEqual([ + 'initialize', + 'session/new', + 'authenticate', + 'session/new' + ]) + expect(agent.frames[2].params).toEqual({ methodId: 'login' }) + }) + + it('chooses an advertised agent authentication method when login is required', async () => { + const { runtime, agent } = fixture() + let authenticated = false + agent.on('initialize', (frame) => + agent.reply(frame, { + protocolVersion: 1, + authMethods: [ + { id: 'terminal', name: 'Interactive login', type: 'terminal' }, + { id: 'agent', name: 'Agent login' } + ] + }) + ) + agent.on('session/new', (frame) => + authenticated + ? agent.reply(frame, { sessionId: 'session-1' }) + : agent.fail(frame, -32000, 'Login required') + ) + agent.on('authenticate', (frame) => { + expect(frame.params).toEqual({ methodId: 'agent' }) + authenticated = true + agent.reply(frame, {}) + }) + await runtime.start(startOptions) + expect(authenticated).toBe(true) + }) + + it('does not retry authentication indefinitely or start an interactive login', async () => { + const { runtime, agent } = fixture() + agent.on('initialize', (frame) => + agent.reply(frame, { + protocolVersion: 1, + authMethods: [{ id: 'terminal', name: 'Interactive login', type: 'terminal' }] + }) + ) + agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Login required')) + await expect(runtime.start(startOptions)).rejects.toBeInstanceOf(AcpAuthRequiredError) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize', 'session/new']) + const retry = fixture() + retry.agent.on('initialize', (frame) => + retry.agent.reply(frame, { + protocolVersion: 1, + authMethods: [{ id: 'agent', name: 'Agent login' }] + }) + ) + retry.agent.on('authenticate', (frame) => retry.agent.reply(frame, {})) + retry.agent.on('session/new', (frame) => + retry.agent.fail(frame, -32000, 'Still requires login') + ) + await expect(retry.runtime.start(startOptions)).rejects.toBeInstanceOf(AcpAuthRequiredError) + expect(retry.agent.frames.map((frame) => frame.method)).toEqual([ + 'initialize', + 'session/new', + 'authenticate', + 'session/new' + ]) + }) + + it('sends mode, model, and config changes to the active session', async () => { + const { runtime, agent } = fixture() + agent.on('session/set_mode', (frame) => agent.reply(frame, {})) + agent.on('session/set_model', (frame) => agent.reply(frame, {})) + agent.on('session/set_config_option', (frame) => { + SetSessionConfigOptionRequestSchema.parse(frame.params) + agent.reply(frame, { configOptions: [] }) + }) + await runtime.start(startOptions) + await runtime.setMode('plan') + await runtime.setModel('model-1') + await runtime.setConfigOption('thinking', 'high') + expect(agent.frames.slice(-3).map((frame) => frame.params)).toEqual([ + { sessionId: 'session-1', modeId: 'plan' }, + { sessionId: 'session-1', modelId: 'model-1' }, + { sessionId: 'session-1', configId: 'thinking', value: 'high' } + ]) + await runtime.setConfigOption('enabled', true) + expect(agent.frames.at(-1)?.params).toEqual({ + sessionId: 'session-1', + configId: 'enabled', + value: true, + type: 'boolean' + }) + }) + + it('rejects an unsupported protocol and malformed session responses', async () => { + const { runtime, agent } = fixture() + agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 2 })) + await expect(runtime.start(startOptions)).rejects.toMatchObject({ code: -32602 }) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize']) + const malformed = fixture() + malformed.agent.on('session/new', (frame) => malformed.agent.reply(frame, {})) + await expect(malformed.runtime.start(startOptions)).rejects.toMatchObject({ code: -32603 }) + }) + + it('rejects invalid permission requests and fails closed on an unavailable selected option', async () => { + const { runtime, agent } = fixture( + {}, + { + onPermission: () => ({ outcome: { outcome: 'selected', optionId: 'not-offered' } }) + } + ) + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const prompt = runtime.prompt([...textPrompt]) + const rejected = expect(prompt).rejects.toBeInstanceOf(AcpConnectionClosedError) + expect(await agent.request('invalid', 'session/request_permission', {})).toMatchObject({ + error: { code: -32602 } + }) + expect( + await agent.request('bad-selection', 'session/request_permission', permission) + ).toMatchObject({ error: { code: -32603 } }) + await expect(runtime.prompt([...textPrompt])).rejects.toThrow('already in progress') + runtime.close() + await rejected + }) + + it('ignores invalid updates and isolates event listener failures', async () => { + const diagnostics: string[] = [] + const { runtime, agent } = fixture({}, { onDiagnostic: (message) => diagnostics.push(message) }) + const updates: SessionNotification[] = [] + runtime.subscribe(() => { + throw new Error('Consumer failed') + }) + runtime.subscribe((event) => updates.push(event)) + await runtime.start(startOptions) + agent.notify('session/update', { + sessionId: 'session-1', + update: { sessionUpdate: 'agent_message_chunk', content: {} } + }) + agent.notify('session/update', { + sessionId: 'session-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'valid', _meta: { vendor: true } } + } + }) + expect(updates).toHaveLength(1) + expect(updates[0].update).toMatchObject({ content: { _meta: { vendor: true } } }) + expect(diagnostics).toContain('Ignored invalid ACP session update') + expect(diagnostics.some((message) => message.includes('Consumer failed'))).toBe(true) + }) + + it('rejects pending calls and aborts permission hooks when the agent exits', async () => { + const requested = deferred() + const { runtime, agent } = fixture( + {}, + { + onPermission: (_request, context) => { + requested.resolve(context.signal) + return new Promise(() => {}) + } + } + ) + agent.on('session/prompt', () => { + void agent.request('permission', 'session/request_permission', permission) + }) + await runtime.start(startOptions) + const pending = runtime.prompt([...textPrompt]) + const rejected = expect(pending).rejects.toBeInstanceOf(AcpConnectionClosedError) + const signal = await requested.promise + agent.stdout.end() + await rejected + expect(signal.aborted).toBe(true) + await expect(runtime.setMode('plan')).rejects.toBeInstanceOf(AcpConnectionClosedError) + }) + + it('closes an unconfirmed cancellation rather than allowing another prompt', async () => { + vi.useFakeTimers() + const { runtime, agent } = fixture({}, { cancelTimeoutMs: 100 }) + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const prompt = runtime.prompt([...textPrompt]) + const rejectedPrompt = expect(prompt).rejects.toBeInstanceOf(AcpRequestTimeoutError) + const cancelled = expect(runtime.cancel()).rejects.toBeInstanceOf(AcpRequestTimeoutError) + await vi.advanceTimersByTimeAsync(100) + await Promise.all([rejectedPrompt, cancelled]) + await expect(runtime.prompt([...textPrompt])).rejects.toBeInstanceOf(AcpRequestTimeoutError) + }) +}) diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts new file mode 100644 index 000000000000..f9296d6c82da --- /dev/null +++ b/src/main/acp/acp-session-runtime.ts @@ -0,0 +1,303 @@ +import type { Readable, Writable } from 'node:stream' +import type { z } from 'zod' +import { AcpRequestTimeoutError, AcpRpcError } from './acp-errors' +import { AcpJsonRpcPeer, type AcpPeerOptions, type AcpRequestContext } from './acp-json-rpc-peer' +import { AcpPermissionRequests, type AcpPermissionHandler } from './acp-permission-requests' +import { + setupAcpSession, + type AcpSessionStarted, + type AcpSessionStartOptions +} from './acp-session-setup' +export type { AcpSessionStarted, AcpSessionStartOptions } from './acp-session-setup' +import { ACP_PROTOCOL_VERSION } from './generated/license.gen' +import { + InitializeResponseSchema, + AuthenticateResponseSchema, + PromptResponseSchema, + RequestPermissionRequestSchema, + SessionNotificationSchema, + SetSessionModeResponseSchema, + SetSessionModelResponseSchema, + SetSessionConfigOptionResponseSchema, + type InitializeRequest, + type InitializeResponse, + type AuthenticateResponse, + type PromptRequest, + type PromptResponse, + type SessionNotification, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, + type SetSessionModeResponse, + type SetSessionModelResponse +} from './generated/protocol.gen' + +export type AcpSessionRuntimeOptions = { + clientInfo?: InitializeRequest['clientInfo'] + peer?: AcpPeerOptions + promptTimeoutMs?: number + cancelTimeoutMs?: number + onPermission?: AcpPermissionHandler + onRequest?: ( + method: string, + params: unknown, + context: AcpRequestContext + ) => unknown | Promise + onDiagnostic?: (message: string) => void + onClose?: (error: Error) => void +} +type ActivePrompt = { + response: Promise + cancelling: boolean + cancelPromise?: Promise +} + +export class AcpSessionRuntime { + private readonly peer: AcpJsonRpcPeer + private readonly permissions = new AcpPermissionRequests() + private readonly listeners = new Set<(event: SessionNotification) => void>() + private initialized?: Promise + private starting?: Promise + private started?: AcpSessionStarted + private activePrompt?: ActivePrompt + + constructor( + input: Readable, + output: Writable, + private readonly options: AcpSessionRuntimeOptions = {} + ) { + for (const timeout of [options.promptTimeoutMs, options.cancelTimeoutMs]) { + if ( + timeout !== undefined && + (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 2_147_483_647) + ) { + throw new Error('ACP timeouts must be positive finite timer durations') + } + } + this.peer = new AcpJsonRpcPeer( + input, + output, + { + onRequest: (method, params, context) => this.handleRequest(method, params, context), + onNotification: (method, params) => this.handleNotification(method, params), + onDiagnostic: options.onDiagnostic, + onClose: options.onClose + }, + options.peer + ) + } + + subscribe(listener: (event: SessionNotification) => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + initialize(): Promise { + this.initialized ??= this.call( + 'initialize', + { + protocolVersion: ACP_PROTOCOL_VERSION, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, + ...(this.options.clientInfo === undefined ? {} : { clientInfo: this.options.clientInfo }) + } satisfies InitializeRequest, + InitializeResponseSchema + ) + .then((response) => { + if (response.protocolVersion !== ACP_PROTOCOL_VERSION) { + const error = new AcpRpcError( + -32602, + `Unsupported ACP protocol version: ${response.protocolVersion}` + ) + this.peer.close(error) + throw error + } + return response + }) + .catch((error) => { + this.initialized = undefined + throw error + }) + return this.initialized + } + + async authenticate(methodId: string): Promise { + await this.initialize() + return this.call('authenticate', { methodId }, AuthenticateResponseSchema) + } + + start(options: AcpSessionStartOptions): Promise { + if (this.started) { + return Promise.resolve(this.started) + } + this.starting ??= this.initialize() + .then((initialized) => + setupAcpSession(initialized, options, (method, params, schema) => + this.call(method, params, schema) + ) + ) + .then((started) => { + this.started = started + return started + }) + .catch((error) => { + this.starting = undefined + throw error + }) + return this.starting + } + + prompt(prompt: PromptRequest['prompt']): Promise { + if (this.activePrompt) { + return Promise.reject(new Error('ACP prompt already in progress')) + } + const sessionId = this.sessionId() + const active: ActivePrompt = { + cancelling: false, + response: this.call('session/prompt', { sessionId, prompt }, PromptResponseSchema, { + timeoutMs: this.options.promptTimeoutMs ?? 30 * 60_000 + }) + } + this.activePrompt = active + active.response = active.response.finally(() => { + this.permissions.cancel(sessionId) + if (this.activePrompt === active) { + this.activePrompt = undefined + } + }) + return active.response + } + + cancel(): Promise { + const sessionId = this.sessionId() + const active = this.activePrompt + if (!active) { + return Promise.resolve() + } + active.cancelling = true + this.permissions.cancel(sessionId) + active.cancelPromise ??= this.cancelActive(sessionId, active) + return active.cancelPromise + } + + private async cancelActive(sessionId: string, active: ActivePrompt): Promise { + const timeoutMs = this.options.cancelTimeoutMs ?? 10_000 + let timer: ReturnType | undefined + try { + await Promise.race([ + this.peer.notify('session/cancel', { sessionId }).then(() => active.response), + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + const error = new AcpRequestTimeoutError('session/cancel') + this.peer.close(error) + reject(error) + }, timeoutMs) + }) + ]) + } finally { + clearTimeout(timer) + } + } + + setMode(modeId: string): Promise { + return this.call( + 'session/set_mode', + { sessionId: this.sessionId(), modeId }, + SetSessionModeResponseSchema + ) + } + setModel(modelId: string): Promise { + return this.call( + 'session/set_model', + { sessionId: this.sessionId(), modelId }, + SetSessionModelResponseSchema + ) + } + setConfigOption( + configId: SetSessionConfigOptionRequest['configId'], + value: SetSessionConfigOptionRequest['value'] + ): Promise { + return this.call( + 'session/set_config_option', + { + configId, + value, + sessionId: this.sessionId(), + ...(typeof value === 'boolean' ? { type: 'boolean' as const } : {}) + } satisfies SetSessionConfigOptionRequest, + SetSessionConfigOptionResponseSchema + ) + } + + close(error?: Error): void { + this.peer.close(error) + this.listeners.clear() + } + + private sessionId(): string { + if (!this.started) { + throw new Error('ACP session has not started') + } + return this.started.sessionId + } + + private async call( + method: string, + params: unknown, + schema: z.ZodType, + options?: { timeoutMs?: number } + ): Promise { + const result = await this.peer.request(method, params, options).catch((error) => { + // A timed-out mutation may still be executing; this runtime cannot safely reuse the session. + if (error instanceof AcpRequestTimeoutError) { + this.peer.close(error) + } + throw error + }) + const parsed = schema.safeParse(result) + if (!parsed.success) { + throw new AcpRpcError(-32603, `Invalid ACP response: ${method}`) + } + return parsed.data + } + + private handleRequest( + method: string, + params: unknown, + context: AcpRequestContext + ): unknown | Promise { + if (method !== 'session/request_permission') { + return this.options.onRequest?.(method, params, context) + } + const parsed = RequestPermissionRequestSchema.safeParse(params) + if (!parsed.success) { + throw new AcpRpcError(-32602, 'Invalid ACP permission request') + } + if ( + !this.activePrompt || + this.activePrompt.cancelling || + parsed.data.sessionId !== this.started?.sessionId + ) { + return { outcome: { outcome: 'cancelled' } } + } + return this.permissions.handle(parsed.data, context, this.options.onPermission) + } + + private handleNotification(method: string, params: unknown): void { + if (method !== 'session/update') { + return + } + const parsed = SessionNotificationSchema.safeParse(params) + if (!parsed.success) { + this.options.onDiagnostic?.('Ignored invalid ACP session update') + return + } + for (const listener of this.listeners) { + try { + listener(parsed.data) + } catch (error) { + this.options.onDiagnostic?.(`ACP event listener failed: ${String(error)}`) + } + } + } +} diff --git a/src/main/acp/acp-session-setup.ts b/src/main/acp/acp-session-setup.ts new file mode 100644 index 000000000000..6c81f0fc78e5 --- /dev/null +++ b/src/main/acp/acp-session-setup.ts @@ -0,0 +1,75 @@ +import type { z } from 'zod' +import { AcpAuthRequiredError, AcpRpcError } from './acp-errors' +import { + NewSessionResponseSchema, + LoadSessionResponseSchema, + ResumeSessionResponseSchema, + AuthenticateResponseSchema, + type InitializeResponse, + type NewSessionRequest, + type NewSessionResponse, + type LoadSessionResponse, + type ResumeSessionResponse +} from './generated/protocol.gen' + +export type AcpSessionStarted = + | { kind: 'new'; sessionId: string; response: NewSessionResponse } + | { kind: 'load'; sessionId: string; response: LoadSessionResponse } + | { kind: 'resume'; sessionId: string; response: ResumeSessionResponse } +export type AcpSessionStartOptions = NewSessionRequest & { + sessionId?: string + resumePreference?: 'load' | 'resume' + authMethodId?: string +} +type Request = (method: string, params: unknown, schema: z.ZodType) => Promise + +export async function setupAcpSession( + initialized: InitializeResponse, + options: AcpSessionStartOptions, + request: Request +): Promise { + const setup = async (): Promise => { + const { sessionId, resumePreference, authMethodId: _auth, ...params } = options + if (sessionId === undefined) { + const response = await request('session/new', params, NewSessionResponseSchema) + return { kind: 'new', sessionId: response.sessionId, response } + } + const capabilities = initialized.agentCapabilities + const load = capabilities?.loadSession === true + const resume = capabilities?.sessionCapabilities?.resume != null + if (load && (resumePreference !== 'resume' || !resume)) { + return { + kind: 'load', + sessionId, + response: await request('session/load', { ...params, sessionId }, LoadSessionResponseSchema) + } + } + if (resume) { + return { + kind: 'resume', + sessionId, + response: await request( + 'session/resume', + { ...params, sessionId }, + ResumeSessionResponseSchema + ) + } + } + throw new AcpRpcError(-32601, 'ACP agent cannot load or resume this session') + } + try { + return await setup() + } catch (error) { + if (!(error instanceof AcpAuthRequiredError)) { + throw error + } + const method = initialized.authMethods?.find((method) => + options.authMethodId ? method.id === options.authMethodId : method.type !== 'terminal' + ) + if (!method || method.type === 'terminal') { + throw error + } + await request('authenticate', { methodId: method.id }, AuthenticateResponseSchema) + return setup() + } +} diff --git a/src/main/acp/acp-write-queue.ts b/src/main/acp/acp-write-queue.ts new file mode 100644 index 000000000000..7538fc04aa66 --- /dev/null +++ b/src/main/acp/acp-write-queue.ts @@ -0,0 +1,119 @@ +import type { Writable } from 'node:stream' +import { AcpConnectionClosedError } from './acp-errors' + +type Write = { + line: string + resolve: () => void + reject: (error: Error) => void + detachAbort?: () => void +} + +export class AcpWriteQueue { + private readonly queue: Write[] = [] + private bytes = 0 + private active?: Write + private terminalError?: Error + private detachDrain?: () => void + + constructor( + private readonly output: Writable, + private readonly maxBytes: number, + private readonly onFailure: (error: Error) => void + ) {} + + write(line: string, signal?: AbortSignal): Promise { + if (this.terminalError) { + return Promise.reject(this.terminalError) + } + if (signal?.aborted) { + return Promise.reject(signal.reason) + } + const bytes = Buffer.byteLength(line) + if (this.bytes + bytes > this.maxBytes) { + return Promise.reject(new Error('ACP write queue capacity exceeded')) + } + this.bytes += bytes + return new Promise((resolve, reject) => { + const write: Write = { line, resolve, reject } + const abort = (): void => { + const index = this.queue.indexOf(write) + if (index === -1) { + return + } + this.queue.splice(index, 1) + this.bytes -= bytes + write.detachAbort?.() + reject(signal?.reason) + } + signal?.addEventListener('abort', abort, { once: true }) + write.detachAbort = () => signal?.removeEventListener('abort', abort) + this.queue.push(write) + this.flush() + }) + } + + close(error: Error): void { + if (this.terminalError) { + return + } + this.terminalError = error + this.detachDrain?.() + this.active?.reject(error) + this.active = undefined + for (const write of this.queue.splice(0)) { + write.detachAbort?.() + write.reject(error) + } + this.bytes = 0 + } + + private flush(): void { + if (this.active || this.terminalError) { + return + } + const write = this.queue.shift() + if (!write) { + return + } + write.detachAbort?.() + this.active = write + if (this.output.destroyed || !this.output.writable) { + this.onFailure(new AcpConnectionClosedError('ACP output is not writable')) + return + } + let completed = false + let drained = false + let returned = false + const finish = (): void => { + if (!returned || !completed || !drained || this.terminalError) { + return + } + this.detachDrain?.() + this.active = undefined + this.bytes -= Buffer.byteLength(write.line) + write.resolve() + this.flush() + } + const onDrain = (): void => { + drained = true + finish() + } + this.output.once('drain', onDrain) + this.detachDrain = () => this.output.removeListener('drain', onDrain) + try { + const accepted = this.output.write(write.line, (error) => { + if (error) { + this.onFailure(error) + return + } + completed = true + finish() + }) + drained ||= accepted + returned = true + finish() + } catch (error) { + this.onFailure(error instanceof Error ? error : new Error(String(error))) + } + } +} diff --git a/src/main/acp/generated/client-capabilities.gen.ts b/src/main/acp/generated/client-capabilities.gen.ts new file mode 100644 index 000000000000..723ef2fddb15 --- /dev/null +++ b/src/main/acp/generated/client-capabilities.gen.ts @@ -0,0 +1,113 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { + FileSystemCapabilitiesSchema, + ClientSessionCapabilitiesSchema, + PlanCapabilitiesSchema, + AuthCapabilitiesSchema, + ElicitationCapabilitiesSchema, + ClientNesCapabilitiesSchema, + PositionEncodingKindSchema, + ProtocolVersionSchema +} from './protocol-version.gen' +export const ClientCapabilitiesSchema = z.looseObject({ + fs: FileSystemCapabilitiesSchema.optional(), + terminal: z.boolean().optional(), + session: z.union([ClientSessionCapabilitiesSchema, z.null()]).optional(), + plan: z.union([PlanCapabilitiesSchema, z.null()]).optional(), + auth: AuthCapabilitiesSchema.optional(), + elicitation: z.union([ElicitationCapabilitiesSchema, z.null()]).optional(), + nes: z.union([ClientNesCapabilitiesSchema, z.null()]).optional(), + positionEncodings: z.array(PositionEncodingKindSchema).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ClientCapabilities = z.infer + +export const ImplementationSchema = z.looseObject({ + name: z.string(), + title: z.union([z.string(), z.null()]).optional(), + version: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Implementation = z.infer + +export const InitializeRequestSchema = z.looseObject({ + protocolVersion: ProtocolVersionSchema, + clientCapabilities: ClientCapabilitiesSchema.optional(), + clientInfo: z.union([ImplementationSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type InitializeRequest = z.infer + +export const PromptCapabilitiesSchema = z.looseObject({ + image: z.boolean().optional(), + audio: z.boolean().optional(), + embeddedContext: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PromptCapabilities = z.infer + +export const McpCapabilitiesSchema = z.looseObject({ + http: z.boolean().optional(), + sse: z.boolean().optional(), + acp: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpCapabilities = z.infer + +export const SessionListCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionListCapabilities = z.infer + +export const SessionDeleteCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionDeleteCapabilities = z.infer + +export const SessionAdditionalDirectoriesCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionAdditionalDirectoriesCapabilities = z.infer< + typeof SessionAdditionalDirectoriesCapabilitiesSchema +> + +export const SessionForkCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionForkCapabilities = z.infer + +export const SessionResumeCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionResumeCapabilities = z.infer + +export const SessionCloseCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionCloseCapabilities = z.infer + +export const SessionCapabilitiesSchema = z.looseObject({ + list: z.union([SessionListCapabilitiesSchema, z.null()]).optional(), + delete: z.union([SessionDeleteCapabilitiesSchema, z.null()]).optional(), + additionalDirectories: z + .union([SessionAdditionalDirectoriesCapabilitiesSchema, z.null()]) + .optional(), + fork: z.union([SessionForkCapabilitiesSchema, z.null()]).optional(), + resume: z.union([SessionResumeCapabilitiesSchema, z.null()]).optional(), + close: z.union([SessionCloseCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionCapabilities = z.infer + +export const LogoutCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type LogoutCapabilities = z.infer + +export const AgentAuthCapabilitiesSchema = z.looseObject({ + logout: z.union([LogoutCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AgentAuthCapabilities = z.infer diff --git a/src/main/acp/generated/diff.gen.ts b/src/main/acp/generated/diff.gen.ts new file mode 100644 index 000000000000..21ec2dbe11ba --- /dev/null +++ b/src/main/acp/generated/diff.gen.ts @@ -0,0 +1,126 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { + ContentSchema, + ToolCallIdSchema, + ToolKindSchema, + ToolCallStatusSchema +} from './text-resource-contents.gen' +export const DiffSchema = z.looseObject({ + path: z.string(), + oldText: z.union([z.string(), z.null()]).optional(), + newText: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Diff = z.infer + +export const TerminalIdSchema = z.string() +export type TerminalId = z.infer + +export const TerminalSchema = z.looseObject({ + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Terminal = z.infer + +export const ToolCallContentSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('content') }), ContentSchema), + z.intersection(z.looseObject({ type: z.literal('diff') }), DiffSchema), + z.intersection(z.looseObject({ type: z.literal('terminal') }), TerminalSchema) +]) +export type ToolCallContent = z.infer + +export const ToolCallLocationSchema = z.looseObject({ + path: z.string(), + line: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ToolCallLocation = z.infer + +export const ToolCallSchema = z.looseObject({ + toolCallId: ToolCallIdSchema, + title: z.string(), + name: z.union([z.string(), z.null()]).optional(), + kind: ToolKindSchema.optional(), + status: ToolCallStatusSchema.optional(), + content: z.array(ToolCallContentSchema).optional(), + locations: z.array(ToolCallLocationSchema).optional(), + rawInput: z.unknown().optional(), + rawOutput: z.unknown().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ToolCall = z.infer + +export const ToolCallUpdateSchema = z.looseObject({ + toolCallId: ToolCallIdSchema, + kind: z.union([ToolKindSchema, z.null()]).optional(), + status: z.union([ToolCallStatusSchema, z.null()]).optional(), + title: z.union([z.string(), z.null()]).optional(), + name: z.union([z.string(), z.null()]).optional(), + content: z.union([z.array(ToolCallContentSchema), z.null()]).optional(), + locations: z.union([z.array(ToolCallLocationSchema), z.null()]).optional(), + rawInput: z.unknown().optional(), + rawOutput: z.unknown().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ToolCallUpdate = z.infer + +export const PlanEntryPrioritySchema = z.union([ + z.literal('high'), + z.literal('medium'), + z.literal('low') +]) +export type PlanEntryPriority = z.infer + +export const PlanEntryStatusSchema = z.union([ + z.literal('pending'), + z.literal('in_progress'), + z.literal('completed') +]) +export type PlanEntryStatus = z.infer + +export const PlanEntrySchema = z.looseObject({ + content: z.string(), + priority: PlanEntryPrioritySchema, + status: PlanEntryStatusSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanEntry = z.infer + +export const PlanSchema = z.looseObject({ + entries: z.array(PlanEntrySchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Plan = z.infer + +export const PlanIdSchema = z.string() +export type PlanId = z.infer + +export const PlanItemsSchema = z.looseObject({ + planId: PlanIdSchema, + entries: z.array(PlanEntrySchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanItems = z.infer + +export const PlanFileSchema = z.looseObject({ + planId: PlanIdSchema, + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanFile = z.infer + +export const PlanMarkdownSchema = z.looseObject({ + planId: PlanIdSchema, + content: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanMarkdown = z.infer + +export const PlanUpdateContentSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('items') }), PlanItemsSchema), + z.intersection(z.looseObject({ type: z.literal('file') }), PlanFileSchema), + z.intersection(z.looseObject({ type: z.literal('markdown') }), PlanMarkdownSchema) +]) +export type PlanUpdateContent = z.infer diff --git a/src/main/acp/generated/license.gen.ts b/src/main/acp/generated/license.gen.ts new file mode 100644 index 000000000000..51e227cb3ce1 --- /dev/null +++ b/src/main/acp/generated/license.gen.ts @@ -0,0 +1,197 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +/* +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 Zed Industries, Inc. and contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +export const ACP_SCHEMA_RELEASE = 'schema-v1.21.0' +export const ACP_LEGACY_MODEL_SCHEMA_RELEASE = 'v0.11.6' +export const ACP_PROTOCOL_VERSION = 1 diff --git a/src/main/acp/generated/mcp-server-acp.gen.ts b/src/main/acp/generated/mcp-server-acp.gen.ts new file mode 100644 index 000000000000..105054e2a883 --- /dev/null +++ b/src/main/acp/generated/mcp-server-acp.gen.ts @@ -0,0 +1,116 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { + McpServerAcpIdSchema, + McpServerHttpSchema, + McpServerSseSchema +} from './nes-context-capabilities.gen' +export const McpServerAcpSchema = z.looseObject({ + name: z.string(), + serverId: McpServerAcpIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerAcp = z.infer + +export const EnvVariableSchema = z.looseObject({ + name: z.string(), + value: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type EnvVariable = z.infer + +export const McpServerStdioSchema = z.looseObject({ + name: z.string(), + command: z.string(), + args: z.array(z.string()), + env: z.array(EnvVariableSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerStdio = z.infer + +export const McpServerSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('http') }), McpServerHttpSchema), + z.intersection(z.looseObject({ type: z.literal('sse') }), McpServerSseSchema), + z.intersection(z.looseObject({ type: z.literal('acp') }), McpServerAcpSchema), + McpServerStdioSchema +]) +export type McpServer = z.infer + +export const NewSessionRequestSchema = z.looseObject({ + cwd: z.string(), + additionalDirectories: z.array(z.string()).optional(), + mcpServers: z.array(McpServerSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NewSessionRequest = z.infer + +export const SessionIdSchema = z.string() +export type SessionId = z.infer + +export const SessionModeIdSchema = z.string() +export type SessionModeId = z.infer + +export const SessionModeSchema = z.looseObject({ + id: SessionModeIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionMode = z.infer + +export const SessionModeStateSchema = z.looseObject({ + currentModeId: SessionModeIdSchema, + availableModes: z.array(SessionModeSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionModeState = z.infer + +export const SessionConfigIdSchema = z.string() +export type SessionConfigId = z.infer + +export const SessionConfigOptionCategorySchema = z.union([ + z.literal('mode'), + z.literal('model'), + z.literal('model_config'), + z.literal('thought_level'), + z.string() +]) +export type SessionConfigOptionCategory = z.infer + +export const SessionConfigValueIdSchema = z.string() +export type SessionConfigValueId = z.infer + +export const SessionConfigSelectOptionSchema = z.looseObject({ + value: SessionConfigValueIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionConfigSelectOption = z.infer + +export const SessionConfigGroupIdSchema = z.string() +export type SessionConfigGroupId = z.infer + +export const SessionConfigSelectGroupSchema = z.looseObject({ + group: SessionConfigGroupIdSchema, + name: z.string(), + options: z.array(SessionConfigSelectOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionConfigSelectGroup = z.infer + +export const SessionConfigSelectOptionsSchema = z.union([ + z.array(SessionConfigSelectOptionSchema), + z.array(SessionConfigSelectGroupSchema) +]) +export type SessionConfigSelectOptions = z.infer + +export const SessionConfigSelectSchema = z.looseObject({ + currentValue: SessionConfigValueIdSchema, + options: SessionConfigSelectOptionsSchema +}) +export type SessionConfigSelect = z.infer + +export const SessionConfigBooleanSchema = z.looseObject({ currentValue: z.boolean() }) +export type SessionConfigBoolean = z.infer diff --git a/src/main/acp/generated/nes-context-capabilities.gen.ts b/src/main/acp/generated/nes-context-capabilities.gen.ts new file mode 100644 index 000000000000..3f193cd78045 --- /dev/null +++ b/src/main/acp/generated/nes-context-capabilities.gen.ts @@ -0,0 +1,124 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { + NesRecentFilesCapabilitiesSchema, + NesRelatedSnippetsCapabilitiesSchema, + NesEditHistoryCapabilitiesSchema, + NesUserActionsCapabilitiesSchema, + NesOpenFilesCapabilitiesSchema, + NesDiagnosticsCapabilitiesSchema, + NesEventCapabilitiesSchema, + ProvidersCapabilitiesSchema +} from './providers-capabilities.gen' +import { + PromptCapabilitiesSchema, + McpCapabilitiesSchema, + SessionCapabilitiesSchema, + AgentAuthCapabilitiesSchema, + ImplementationSchema +} from './client-capabilities.gen' +import { PositionEncodingKindSchema, ProtocolVersionSchema } from './protocol-version.gen' +export const NesContextCapabilitiesSchema = z.looseObject({ + recentFiles: z.union([NesRecentFilesCapabilitiesSchema, z.null()]).optional(), + relatedSnippets: z.union([NesRelatedSnippetsCapabilitiesSchema, z.null()]).optional(), + editHistory: z.union([NesEditHistoryCapabilitiesSchema, z.null()]).optional(), + userActions: z.union([NesUserActionsCapabilitiesSchema, z.null()]).optional(), + openFiles: z.union([NesOpenFilesCapabilitiesSchema, z.null()]).optional(), + diagnostics: z.union([NesDiagnosticsCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesContextCapabilities = z.infer + +export const NesCapabilitiesSchema = z.looseObject({ + events: z.union([NesEventCapabilitiesSchema, z.null()]).optional(), + context: z.union([NesContextCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesCapabilities = z.infer + +export const AgentCapabilitiesSchema = z.looseObject({ + loadSession: z.boolean().optional(), + promptCapabilities: PromptCapabilitiesSchema.optional(), + mcpCapabilities: McpCapabilitiesSchema.optional(), + sessionCapabilities: SessionCapabilitiesSchema.optional(), + auth: AgentAuthCapabilitiesSchema.optional(), + providers: z.union([ProvidersCapabilitiesSchema, z.null()]).optional(), + nes: z.union([NesCapabilitiesSchema, z.null()]).optional(), + positionEncoding: z.union([PositionEncodingKindSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AgentCapabilities = z.infer + +export const AuthMethodIdSchema = z.string() +export type AuthMethodId = z.infer + +export const AuthMethodTerminalSchema = z.looseObject({ + id: AuthMethodIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + args: z.array(z.string()).optional(), + env: z.looseObject({}).catchall(z.string()).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthMethodTerminal = z.infer + +export const AuthMethodAgentSchema = z.looseObject({ + id: AuthMethodIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthMethodAgent = z.infer + +export const AuthMethodSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('terminal') }), AuthMethodTerminalSchema), + AuthMethodAgentSchema +]) +export type AuthMethod = z.infer + +export const InitializeResponseSchema = z.looseObject({ + protocolVersion: ProtocolVersionSchema, + agentCapabilities: AgentCapabilitiesSchema.optional(), + authMethods: z.array(AuthMethodSchema).optional(), + agentInfo: z.union([ImplementationSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type InitializeResponse = z.infer + +export const AuthenticateRequestSchema = z.looseObject({ + methodId: AuthMethodIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthenticateRequest = z.infer + +export const AuthenticateResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthenticateResponse = z.infer + +export const HttpHeaderSchema = z.looseObject({ + name: z.string(), + value: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type HttpHeader = z.infer + +export const McpServerHttpSchema = z.looseObject({ + name: z.string(), + url: z.string(), + headers: z.array(HttpHeaderSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerHttp = z.infer + +export const McpServerSseSchema = z.looseObject({ + name: z.string(), + url: z.string(), + headers: z.array(HttpHeaderSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerSse = z.infer + +export const McpServerAcpIdSchema = z.string() +export type McpServerAcpId = z.infer diff --git a/src/main/acp/generated/plan-update.gen.ts b/src/main/acp/generated/plan-update.gen.ts new file mode 100644 index 000000000000..eda1e7474274 --- /dev/null +++ b/src/main/acp/generated/plan-update.gen.ts @@ -0,0 +1,103 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { PlanUpdateContentSchema, PlanIdSchema } from './diff.gen' +import { SessionModeIdSchema } from './mcp-server-acp.gen' +import { SessionConfigOptionSchema } from './session-config-option.gen' +import { ContentBlockSchema } from './text-resource-contents.gen' +export const PlanUpdateSchema = z.looseObject({ + plan: PlanUpdateContentSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanUpdate = z.infer + +export const PlanRemovedSchema = z.looseObject({ + planId: PlanIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanRemoved = z.infer + +export const UnstructuredCommandInputSchema = z.looseObject({ + hint: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type UnstructuredCommandInput = z.infer + +export const AvailableCommandInputSchema = z.union([UnstructuredCommandInputSchema]) +export type AvailableCommandInput = z.infer + +export const AvailableCommandSchema = z.looseObject({ + name: z.string(), + description: z.string(), + input: z.union([AvailableCommandInputSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AvailableCommand = z.infer + +export const AvailableCommandsUpdateSchema = z.looseObject({ + availableCommands: z.array(AvailableCommandSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AvailableCommandsUpdate = z.infer + +export const CurrentModeUpdateSchema = z.looseObject({ + currentModeId: SessionModeIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CurrentModeUpdate = z.infer + +export const ConfigOptionUpdateSchema = z.looseObject({ + configOptions: z.array(SessionConfigOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ConfigOptionUpdate = z.infer + +export const SessionInfoUpdateSchema = z.looseObject({ + title: z.union([z.string(), z.null()]).optional(), + updatedAt: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionInfoUpdate = z.infer + +export const CostSchema = z.looseObject({ + amount: z.number(), + currency: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Cost = z.infer + +export const UsageUpdateSchema = z.looseObject({ + used: z.number().int().min(0), + size: z.number().int().min(0), + cost: z.union([CostSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type UsageUpdate = z.infer + +export const CompactionIdSchema = z.string() +export type CompactionId = z.infer + +export const CompactionStatusSchema = z.union([ + z.literal('in_progress'), + z.literal('completed'), + z.literal('failed'), + z.literal('cancelled'), + z.string() +]) +export type CompactionStatus = z.infer + +export const CompactionUpdateSchema = z.looseObject({ + compactionId: CompactionIdSchema, + status: CompactionStatusSchema, + summary: z.union([z.array(ContentBlockSchema), z.null()]).optional(), + error: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CompactionUpdate = z.infer + +export const CompactionSummaryChunkSchema = z.looseObject({ + compactionId: CompactionIdSchema, + content: ContentBlockSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CompactionSummaryChunk = z.infer diff --git a/src/main/acp/generated/protocol-version.gen.ts b/src/main/acp/generated/protocol-version.gen.ts new file mode 100644 index 000000000000..865debf5666e --- /dev/null +++ b/src/main/acp/generated/protocol-version.gen.ts @@ -0,0 +1,93 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +export const ProtocolVersionSchema = z.number().int().min(0).max(65535) +export type ProtocolVersion = z.infer + +export const FileSystemCapabilitiesSchema = z.looseObject({ + readTextFile: z.boolean().optional(), + writeTextFile: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type FileSystemCapabilities = z.infer + +export const CompactionCapabilitiesSchema = z.looseObject({}) +export type CompactionCapabilities = z.infer + +export const BooleanConfigOptionCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type BooleanConfigOptionCapabilities = z.infer + +export const SessionConfigOptionsCapabilitiesSchema = z.looseObject({ + boolean: z.union([BooleanConfigOptionCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionConfigOptionsCapabilities = z.infer< + typeof SessionConfigOptionsCapabilitiesSchema +> + +export const ClientSessionCapabilitiesSchema = z.looseObject({ + compaction: z.union([CompactionCapabilitiesSchema, z.null()]).optional(), + configOptions: z.union([SessionConfigOptionsCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ClientSessionCapabilities = z.infer + +export const PlanCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanCapabilities = z.infer + +export const AuthCapabilitiesSchema = z.looseObject({ + terminal: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthCapabilities = z.infer + +export const ElicitationFormCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ElicitationFormCapabilities = z.infer + +export const ElicitationUrlCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ElicitationUrlCapabilities = z.infer + +export const ElicitationCapabilitiesSchema = z.looseObject({ + form: z.union([ElicitationFormCapabilitiesSchema, z.null()]).optional(), + url: z.union([ElicitationUrlCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ElicitationCapabilities = z.infer + +export const NesJumpCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesJumpCapabilities = z.infer + +export const NesRenameCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesRenameCapabilities = z.infer + +export const NesSearchAndReplaceCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesSearchAndReplaceCapabilities = z.infer + +export const ClientNesCapabilitiesSchema = z.looseObject({ + jump: z.union([NesJumpCapabilitiesSchema, z.null()]).optional(), + rename: z.union([NesRenameCapabilitiesSchema, z.null()]).optional(), + searchAndReplace: z.union([NesSearchAndReplaceCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ClientNesCapabilities = z.infer + +export const PositionEncodingKindSchema = z.union([ + z.literal('utf-16'), + z.literal('utf-32'), + z.literal('utf-8') +]) +export type PositionEncodingKind = z.infer diff --git a/src/main/acp/generated/protocol.gen.ts b/src/main/acp/generated/protocol.gen.ts new file mode 100644 index 000000000000..1c1cd14183c8 --- /dev/null +++ b/src/main/acp/generated/protocol.gen.ts @@ -0,0 +1,14 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +export * from './protocol-version.gen' +export * from './client-capabilities.gen' +export * from './providers-capabilities.gen' +export * from './nes-context-capabilities.gen' +export * from './mcp-server-acp.gen' +export * from './session-config-option.gen' +export * from './text-resource-contents.gen' +export * from './diff.gen' +export * from './plan-update.gen' +export * from './session-update.gen' +export * from './set-session-model-request.gen' +export * from './wait-for-terminal-exit-request.gen' diff --git a/src/main/acp/generated/providers-capabilities.gen.ts b/src/main/acp/generated/providers-capabilities.gen.ts new file mode 100644 index 000000000000..c9864207ed26 --- /dev/null +++ b/src/main/acp/generated/providers-capabilities.gen.ts @@ -0,0 +1,87 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +export const ProvidersCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ProvidersCapabilities = z.infer + +export const NesDocumentDidOpenCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidOpenCapabilities = z.infer + +export const TextDocumentSyncKindSchema = z.union([z.literal('full'), z.literal('incremental')]) +export type TextDocumentSyncKind = z.infer + +export const NesDocumentDidChangeCapabilitiesSchema = z.looseObject({ + syncKind: TextDocumentSyncKindSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidChangeCapabilities = z.infer< + typeof NesDocumentDidChangeCapabilitiesSchema +> + +export const NesDocumentDidCloseCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidCloseCapabilities = z.infer + +export const NesDocumentDidSaveCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidSaveCapabilities = z.infer + +export const NesDocumentDidFocusCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidFocusCapabilities = z.infer + +export const NesDocumentEventCapabilitiesSchema = z.looseObject({ + didOpen: z.union([NesDocumentDidOpenCapabilitiesSchema, z.null()]).optional(), + didChange: z.union([NesDocumentDidChangeCapabilitiesSchema, z.null()]).optional(), + didClose: z.union([NesDocumentDidCloseCapabilitiesSchema, z.null()]).optional(), + didSave: z.union([NesDocumentDidSaveCapabilitiesSchema, z.null()]).optional(), + didFocus: z.union([NesDocumentDidFocusCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentEventCapabilities = z.infer + +export const NesEventCapabilitiesSchema = z.looseObject({ + document: z.union([NesDocumentEventCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesEventCapabilities = z.infer + +export const NesRecentFilesCapabilitiesSchema = z.looseObject({ + maxCount: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesRecentFilesCapabilities = z.infer + +export const NesRelatedSnippetsCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesRelatedSnippetsCapabilities = z.infer + +export const NesEditHistoryCapabilitiesSchema = z.looseObject({ + maxCount: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesEditHistoryCapabilities = z.infer + +export const NesUserActionsCapabilitiesSchema = z.looseObject({ + maxCount: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesUserActionsCapabilities = z.infer + +export const NesOpenFilesCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesOpenFilesCapabilities = z.infer + +export const NesDiagnosticsCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDiagnosticsCapabilities = z.infer diff --git a/src/main/acp/generated/session-config-option.gen.ts b/src/main/acp/generated/session-config-option.gen.ts new file mode 100644 index 000000000000..3d33a869a6fc --- /dev/null +++ b/src/main/acp/generated/session-config-option.gen.ts @@ -0,0 +1,113 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { + SessionConfigIdSchema, + SessionConfigOptionCategorySchema, + SessionConfigSelectSchema, + SessionConfigBooleanSchema, + SessionIdSchema, + SessionModeStateSchema, + McpServerSchema +} from './mcp-server-acp.gen' +export const SessionConfigOptionSchema = z.intersection( + z.looseObject({ + id: SessionConfigIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + category: z.union([SessionConfigOptionCategorySchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() + }), + z.union([ + z.intersection(z.looseObject({ type: z.literal('select') }), SessionConfigSelectSchema), + z.intersection(z.looseObject({ type: z.literal('boolean') }), SessionConfigBooleanSchema) + ]) +) +export type SessionConfigOption = z.infer + +export const NewSessionResponseSchema = z.looseObject({ + sessionId: SessionIdSchema, + modes: z.union([SessionModeStateSchema, z.null()]).optional(), + configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NewSessionResponse = z.infer + +export const LoadSessionRequestSchema = z.looseObject({ + mcpServers: z.array(McpServerSchema), + cwd: z.string(), + additionalDirectories: z.array(z.string()).optional(), + sessionId: SessionIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type LoadSessionRequest = z.infer + +export const LoadSessionResponseSchema = z.looseObject({ + modes: z.union([SessionModeStateSchema, z.null()]).optional(), + configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type LoadSessionResponse = z.infer + +export const ResumeSessionRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + cwd: z.string(), + additionalDirectories: z.array(z.string()).optional(), + mcpServers: z.array(McpServerSchema).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ResumeSessionRequest = z.infer + +export const ResumeSessionResponseSchema = z.looseObject({ + modes: z.union([SessionModeStateSchema, z.null()]).optional(), + configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ResumeSessionResponse = z.infer + +export const RoleSchema = z.union([z.literal('assistant'), z.literal('user')]) +export type Role = z.infer + +export const AnnotationsSchema = z.looseObject({ + audience: z.union([z.array(RoleSchema), z.null()]).optional(), + lastModified: z.union([z.string(), z.null()]).optional(), + priority: z.union([z.number(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Annotations = z.infer + +export const TextContentSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + text: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TextContent = z.infer + +export const ImageContentSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + data: z.string(), + mimeType: z.string(), + uri: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ImageContent = z.infer + +export const AudioContentSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + data: z.string(), + mimeType: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AudioContent = z.infer + +export const ResourceLinkSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + description: z.union([z.string(), z.null()]).optional(), + mimeType: z.union([z.string(), z.null()]).optional(), + name: z.string(), + size: z.union([z.number().int(), z.null()]).optional(), + title: z.union([z.string(), z.null()]).optional(), + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ResourceLink = z.infer diff --git a/src/main/acp/generated/session-update.gen.ts b/src/main/acp/generated/session-update.gen.ts new file mode 100644 index 000000000000..7b67951065d7 --- /dev/null +++ b/src/main/acp/generated/session-update.gen.ts @@ -0,0 +1,132 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { ContentChunkSchema } from './text-resource-contents.gen' +import { ToolCallSchema, ToolCallUpdateSchema, PlanSchema } from './diff.gen' +import { + PlanUpdateSchema, + PlanRemovedSchema, + AvailableCommandsUpdateSchema, + CurrentModeUpdateSchema, + ConfigOptionUpdateSchema, + SessionInfoUpdateSchema, + UsageUpdateSchema, + CompactionUpdateSchema, + CompactionSummaryChunkSchema +} from './plan-update.gen' +import { SessionIdSchema, SessionModeIdSchema } from './mcp-server-acp.gen' +export const SessionUpdateSchema = z.union([ + z.intersection( + z.looseObject({ sessionUpdate: z.literal('user_message_chunk') }), + ContentChunkSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('agent_message_chunk') }), + ContentChunkSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('agent_thought_chunk') }), + ContentChunkSchema + ), + z.intersection(z.looseObject({ sessionUpdate: z.literal('tool_call') }), ToolCallSchema), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('tool_call_update') }), + ToolCallUpdateSchema + ), + z.intersection(z.looseObject({ sessionUpdate: z.literal('plan') }), PlanSchema), + z.intersection(z.looseObject({ sessionUpdate: z.literal('plan_update') }), PlanUpdateSchema), + z.intersection(z.looseObject({ sessionUpdate: z.literal('plan_removed') }), PlanRemovedSchema), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('available_commands_update') }), + AvailableCommandsUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('current_mode_update') }), + CurrentModeUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('config_option_update') }), + ConfigOptionUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('session_info_update') }), + SessionInfoUpdateSchema + ), + z.intersection(z.looseObject({ sessionUpdate: z.literal('usage_update') }), UsageUpdateSchema), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('compaction_update') }), + CompactionUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('compaction_summary_chunk') }), + CompactionSummaryChunkSchema + ) +]) +export type SessionUpdate = z.infer + +export const SessionNotificationSchema = z.looseObject({ + sessionId: SessionIdSchema, + update: SessionUpdateSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionNotification = z.infer + +export const PermissionOptionIdSchema = z.string() +export type PermissionOptionId = z.infer + +export const PermissionOptionKindSchema = z.union([ + z.literal('allow_once'), + z.literal('allow_always'), + z.literal('reject_once'), + z.literal('reject_always') +]) +export type PermissionOptionKind = z.infer + +export const PermissionOptionSchema = z.looseObject({ + optionId: PermissionOptionIdSchema, + name: z.string(), + kind: PermissionOptionKindSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PermissionOption = z.infer + +export const RequestPermissionRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + toolCall: ToolCallUpdateSchema, + options: z.array(PermissionOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type RequestPermissionRequest = z.infer + +export const SelectedPermissionOutcomeSchema = z.looseObject({ + optionId: PermissionOptionIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SelectedPermissionOutcome = z.infer + +export const RequestPermissionOutcomeSchema = z.union([ + z.looseObject({ outcome: z.literal('cancelled') }), + z.intersection(z.looseObject({ outcome: z.literal('selected') }), SelectedPermissionOutcomeSchema) +]) +export type RequestPermissionOutcome = z.infer + +export const RequestPermissionResponseSchema = z.looseObject({ + outcome: RequestPermissionOutcomeSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type RequestPermissionResponse = z.infer + +export const SetSessionModeRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + modeId: SessionModeIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionModeRequest = z.infer + +export const SetSessionModeResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionModeResponse = z.infer + +export const ModelIdSchema = z.string() +export type ModelId = z.infer diff --git a/src/main/acp/generated/set-session-model-request.gen.ts b/src/main/acp/generated/set-session-model-request.gen.ts new file mode 100644 index 000000000000..c25c1f98808e --- /dev/null +++ b/src/main/acp/generated/set-session-model-request.gen.ts @@ -0,0 +1,121 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { ModelIdSchema } from './session-update.gen' +import { + SessionIdSchema, + SessionConfigIdSchema, + SessionConfigValueIdSchema, + EnvVariableSchema +} from './mcp-server-acp.gen' +import { SessionConfigOptionSchema } from './session-config-option.gen' +import { TerminalIdSchema } from './diff.gen' +export const SetSessionModelRequestSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional(), + modelId: ModelIdSchema, + sessionId: SessionIdSchema +}) +export type SetSessionModelRequest = z.infer + +export const SetSessionModelResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionModelResponse = z.infer + +export const SetSessionConfigOptionRequestSchema = z.intersection( + z.looseObject({ + sessionId: SessionIdSchema, + configId: SessionConfigIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() + }), + z.union([ + z.looseObject({ value: z.boolean(), type: z.literal('boolean') }), + z.looseObject({ value: SessionConfigValueIdSchema }) + ]) +) +export type SetSessionConfigOptionRequest = z.infer + +export const SetSessionConfigOptionResponseSchema = z.looseObject({ + configOptions: z.array(SessionConfigOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionConfigOptionResponse = z.infer + +export const ReadTextFileRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + path: z.string(), + line: z.union([z.number().int().min(0), z.null()]).optional(), + limit: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReadTextFileRequest = z.infer + +export const ReadTextFileResponseSchema = z.looseObject({ + content: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReadTextFileResponse = z.infer + +export const WriteTextFileRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + path: z.string(), + content: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WriteTextFileRequest = z.infer + +export const WriteTextFileResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WriteTextFileResponse = z.infer + +export const CreateTerminalRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + command: z.string(), + args: z.array(z.string()).optional(), + env: z.array(EnvVariableSchema).optional(), + cwd: z.union([z.string(), z.null()]).optional(), + outputByteLimit: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CreateTerminalRequest = z.infer + +export const CreateTerminalResponseSchema = z.looseObject({ + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CreateTerminalResponse = z.infer + +export const TerminalOutputRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TerminalOutputRequest = z.infer + +export const TerminalExitStatusSchema = z.looseObject({ + exitCode: z.union([z.number().int().min(0), z.null()]).optional(), + signal: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TerminalExitStatus = z.infer + +export const TerminalOutputResponseSchema = z.looseObject({ + output: z.string(), + truncated: z.boolean(), + exitStatus: z.union([TerminalExitStatusSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TerminalOutputResponse = z.infer + +export const ReleaseTerminalRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReleaseTerminalRequest = z.infer + +export const ReleaseTerminalResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReleaseTerminalResponse = z.infer diff --git a/src/main/acp/generated/text-resource-contents.gen.ts b/src/main/acp/generated/text-resource-contents.gen.ts new file mode 100644 index 000000000000..c69e01fb6fb1 --- /dev/null +++ b/src/main/acp/generated/text-resource-contents.gen.ts @@ -0,0 +1,129 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { + AnnotationsSchema, + TextContentSchema, + ImageContentSchema, + AudioContentSchema, + ResourceLinkSchema +} from './session-config-option.gen' +import { SessionIdSchema } from './mcp-server-acp.gen' +export const TextResourceContentsSchema = z.looseObject({ + mimeType: z.union([z.string(), z.null()]).optional(), + text: z.string(), + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TextResourceContents = z.infer + +export const BlobResourceContentsSchema = z.looseObject({ + blob: z.string(), + mimeType: z.union([z.string(), z.null()]).optional(), + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type BlobResourceContents = z.infer + +export const EmbeddedResourceResourceSchema = z.union([ + TextResourceContentsSchema, + BlobResourceContentsSchema +]) +export type EmbeddedResourceResource = z.infer + +export const EmbeddedResourceSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + resource: EmbeddedResourceResourceSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type EmbeddedResource = z.infer + +export const ContentBlockSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('text') }), TextContentSchema), + z.intersection(z.looseObject({ type: z.literal('image') }), ImageContentSchema), + z.intersection(z.looseObject({ type: z.literal('audio') }), AudioContentSchema), + z.intersection(z.looseObject({ type: z.literal('resource_link') }), ResourceLinkSchema), + z.intersection(z.looseObject({ type: z.literal('resource') }), EmbeddedResourceSchema) +]) +export type ContentBlock = z.infer + +export const PromptRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + prompt: z.array(ContentBlockSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PromptRequest = z.infer + +export const StopReasonSchema = z.union([ + z.literal('end_turn'), + z.literal('max_tokens'), + z.literal('max_turn_requests'), + z.literal('refusal'), + z.literal('cancelled') +]) +export type StopReason = z.infer + +export const UsageSchema = z.looseObject({ + totalTokens: z.number().int().min(0), + inputTokens: z.number().int().min(0), + outputTokens: z.number().int().min(0), + thoughtTokens: z.union([z.number().int().min(0), z.null()]).optional(), + cachedReadTokens: z.union([z.number().int().min(0), z.null()]).optional(), + cachedWriteTokens: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Usage = z.infer + +export const PromptResponseSchema = z.looseObject({ + stopReason: StopReasonSchema, + usage: z.union([UsageSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PromptResponse = z.infer + +export const CancelNotificationSchema = z.looseObject({ + sessionId: SessionIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CancelNotification = z.infer + +export const MessageIdSchema = z.string() +export type MessageId = z.infer + +export const ContentChunkSchema = z.looseObject({ + content: ContentBlockSchema, + messageId: z.union([MessageIdSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ContentChunk = z.infer + +export const ToolCallIdSchema = z.string() +export type ToolCallId = z.infer + +export const ToolKindSchema = z.union([ + z.literal('read'), + z.literal('edit'), + z.literal('delete'), + z.literal('move'), + z.literal('search'), + z.literal('execute'), + z.literal('think'), + z.literal('fetch'), + z.literal('switch_mode'), + z.literal('other') +]) +export type ToolKind = z.infer + +export const ToolCallStatusSchema = z.union([ + z.literal('pending'), + z.literal('in_progress'), + z.literal('completed'), + z.literal('failed') +]) +export type ToolCallStatus = z.infer + +export const ContentSchema = z.looseObject({ + content: ContentBlockSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Content = z.infer diff --git a/src/main/acp/generated/wait-for-terminal-exit-request.gen.ts b/src/main/acp/generated/wait-for-terminal-exit-request.gen.ts new file mode 100644 index 000000000000..1f5fc01db7cc --- /dev/null +++ b/src/main/acp/generated/wait-for-terminal-exit-request.gen.ts @@ -0,0 +1,30 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { SessionIdSchema } from './mcp-server-acp.gen' +import { TerminalIdSchema } from './diff.gen' +export const WaitForTerminalExitRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WaitForTerminalExitRequest = z.infer + +export const WaitForTerminalExitResponseSchema = z.looseObject({ + exitCode: z.union([z.number().int().min(0), z.null()]).optional(), + signal: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WaitForTerminalExitResponse = z.infer + +export const KillTerminalRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type KillTerminalRequest = z.infer + +export const KillTerminalResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type KillTerminalResponse = z.infer From d09c278c4b46b12d0fac27488b7bb22a6e19567e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:02:23 -0700 Subject: [PATCH 022/269] Protect ACP transport teardown from late stream errors --- src/main/acp/acp-json-rpc-peer.test.ts | 15 +++++++++++++++ src/main/acp/acp-json-rpc-peer.ts | 5 +++-- src/main/acp/acp-stdio-error-boundary.ts | 16 ++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 src/main/acp/acp-stdio-error-boundary.ts diff --git a/src/main/acp/acp-json-rpc-peer.test.ts b/src/main/acp/acp-json-rpc-peer.test.ts index 3aba7848293a..b68a09c8aec7 100644 --- a/src/main/acp/acp-json-rpc-peer.test.ts +++ b/src/main/acp/acp-json-rpc-peer.test.ts @@ -250,4 +250,19 @@ describe('ACP JSON-RPC peer', () => { await rejected expect(peer.closed).toBe(true) }) + + it('handles asynchronous write callback errors without an unhandled stream error', async () => { + const input = new PassThrough() + const output = new Writable({ + write(_chunk, _encoding, callback) { + setImmediate(() => callback(new Error('Broken pipe from write'))) + } + }) + const peer = new AcpJsonRpcPeer(input, output) + peers.push(peer) + await expect(peer.request('wait', {})).rejects.toThrow('Broken pipe from write') + await tick() + expect(peer.closed).toBe(true) + input.destroy() + }) }) diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index 0924a1f797fa..cac23f60b57e 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -12,6 +12,7 @@ import { } from './acp-errors' import { AcpIncomingRequests } from './acp-incoming-requests' import { AcpWriteQueue } from './acp-write-queue' +import { detachAcpStreamErrorHandler } from './acp-stdio-error-boundary' const idSchema = z.union([z.string(), z.number(), z.null()]) const envelopeSchema = z.looseObject({ @@ -149,10 +150,10 @@ export class AcpJsonRpcPeer { this.input.removeListener('data', this.onData) this.input.removeListener('end', this.onEnd) this.input.removeListener('close', this.onEnd) - this.input.removeListener('error', this.onError) + detachAcpStreamErrorHandler(this.input, this.onError) this.output.removeListener('close', this.onEnd) this.output.removeListener('finish', this.onEnd) - this.output.removeListener('error', this.onError) + detachAcpStreamErrorHandler(this.output, this.onError) this.framer.reset() this.writer.close(error) for (const pending of this.pending.values()) { diff --git a/src/main/acp/acp-stdio-error-boundary.ts b/src/main/acp/acp-stdio-error-boundary.ts new file mode 100644 index 000000000000..67854b6f95a2 --- /dev/null +++ b/src/main/acp/acp-stdio-error-boundary.ts @@ -0,0 +1,16 @@ +import type { Readable, Writable } from 'node:stream' + +function ignoreLateError(): void {} + +export function detachAcpStreamErrorHandler( + stream: Readable | Writable, + handler: (error: Error) => void +): void { + stream.removeListener('error', handler) + if (stream.closed) { + return + } + // Node may emit the write error after its callback has already closed the peer. + stream.on('error', ignoreLateError) + stream.once('close', () => stream.removeListener('error', ignoreLateError)) +} From c3aa650d18eec279ed5090f51c400213b6110ad6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:09:09 -0700 Subject: [PATCH 023/269] Retire incoming ACP request ids before publishing responses --- src/main/acp/acp-incoming-requests.ts | 12 ++++++++++-- src/main/acp/acp-json-rpc-peer.test.ts | 10 ++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts index 5b55fb8d5835..c594e1d31e8a 100644 --- a/src/main/acp/acp-incoming-requests.ts +++ b/src/main/acp/acp-incoming-requests.ts @@ -41,6 +41,12 @@ export class AcpIncomingRequests { ) }) this.open.set(id, { controller, abandon }) + const retire = (): void => { + clearTimeout(timer) + if (this.open.get(id)?.controller === controller) { + this.open.delete(id) + } + } void Promise.race([ deadline, Promise.resolve().then(() => { @@ -57,12 +63,15 @@ export class AcpIncomingRequests { if (result === undefined) { throw new AcpRpcError(-32601, `Unknown ACP client method: ${method}`) } + // The agent may reuse the id as soon as it reads the response. + retire() await this.send({ jsonrpc: '2.0', id, result }) }) .catch(async (error) => { if (controller.signal.aborted) { return } + retire() await this.sendError( id, error instanceof AcpRpcError @@ -71,8 +80,7 @@ export class AcpIncomingRequests { ) }) .finally(() => { - clearTimeout(timer) - this.open.delete(id) + retire() controller.abort() }) } diff --git a/src/main/acp/acp-json-rpc-peer.test.ts b/src/main/acp/acp-json-rpc-peer.test.ts index b68a09c8aec7..1019049d51fb 100644 --- a/src/main/acp/acp-json-rpc-peer.test.ts +++ b/src/main/acp/acp-json-rpc-peer.test.ts @@ -65,6 +65,16 @@ describe('ACP JSON-RPC peer', () => { }) }) + it('allows the agent to reuse a request id after receiving its response', async () => { + const { agent } = fixture({ onRequest: () => ({ answer: true }) }) + expect(await agent.request('reused', '_question', {})).toMatchObject({ + result: { answer: true } + }) + expect(await agent.request('reused', '_question', {})).toMatchObject({ + result: { answer: true } + }) + }) + it('ignores malformed and oversized lines then resumes at the next newline', async () => { const diagnostics: string[] = [] const notified: unknown[] = [] From cdad7d2fb2d2163985374bdf4913ef46400c7307 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:14:08 -0700 Subject: [PATCH 024/269] Narrow ACP configuration requests and transport message types --- src/main/acp/acp-incoming-requests.ts | 4 ++-- src/main/acp/acp-json-rpc-peer.ts | 3 ++- src/main/acp/acp-session-runtime.ts | 16 ++++++---------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts index c594e1d31e8a..ffdeae1a0992 100644 --- a/src/main/acp/acp-incoming-requests.ts +++ b/src/main/acp/acp-incoming-requests.ts @@ -1,5 +1,5 @@ import { AcpRpcError } from './acp-errors' -import type { AcpPeerHandlers } from './acp-json-rpc-peer' +import type { AcpJsonRpcMessage, AcpPeerHandlers } from './acp-json-rpc-peer' type OpenRequest = { controller: AbortController; abandon: () => void } @@ -8,7 +8,7 @@ export class AcpIncomingRequests { constructor( private readonly handler: AcpPeerHandlers['onRequest'], - private readonly send: (message: object) => Promise, + private readonly send: (message: AcpJsonRpcMessage) => Promise, private readonly onFailure: (error: Error) => void, private readonly capacity: number, private readonly timeoutMs: number diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index cac23f60b57e..5fda716003bb 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -25,6 +25,7 @@ const envelopeSchema = z.looseObject({ .object({ code: z.number().int(), message: z.string(), data: z.unknown().optional() }) .optional() }) +export type AcpJsonRpcMessage = z.infer export type AcpRequestContext = { id: string | number | null; signal: AbortSignal } export type AcpPeerHandlers = { onRequest?: ( @@ -186,7 +187,7 @@ export class AcpJsonRpcPeer { } } - private send(message: object, signal?: AbortSignal): Promise { + private send(message: AcpJsonRpcMessage, signal?: AbortSignal): Promise { if (this.terminalError) { return Promise.reject(this.terminalError) } diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index f9296d6c82da..16f8eafe33ec 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -217,16 +217,12 @@ export class AcpSessionRuntime { configId: SetSessionConfigOptionRequest['configId'], value: SetSessionConfigOptionRequest['value'] ): Promise { - return this.call( - 'session/set_config_option', - { - configId, - value, - sessionId: this.sessionId(), - ...(typeof value === 'boolean' ? { type: 'boolean' as const } : {}) - } satisfies SetSessionConfigOptionRequest, - SetSessionConfigOptionResponseSchema - ) + const sessionId = this.sessionId() + const request = + typeof value === 'boolean' + ? ({ configId, value, sessionId, type: 'boolean' } satisfies SetSessionConfigOptionRequest) + : ({ configId, value, sessionId } satisfies SetSessionConfigOptionRequest) + return this.call('session/set_config_option', request, SetSessionConfigOptionResponseSchema) } close(error?: Error): void { From 2ae4ab5452de296918344e8711ebd7b0e06bf72c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:18:14 -0700 Subject: [PATCH 025/269] Remove redundant ACP request handler return unions --- src/main/acp/acp-json-rpc-peer.ts | 6 +----- src/main/acp/acp-session-runtime.ts | 12 ++---------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index 5fda716003bb..f48a1b63ad01 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -28,11 +28,7 @@ const envelopeSchema = z.looseObject({ export type AcpJsonRpcMessage = z.infer export type AcpRequestContext = { id: string | number | null; signal: AbortSignal } export type AcpPeerHandlers = { - onRequest?: ( - method: string, - params: unknown, - context: AcpRequestContext - ) => unknown | Promise + onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown onNotification?: (method: string, params: unknown) => void onDiagnostic?: (message: string) => void onClose?: (error: Error) => void diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 16f8eafe33ec..095573150ce6 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -37,11 +37,7 @@ export type AcpSessionRuntimeOptions = { promptTimeoutMs?: number cancelTimeoutMs?: number onPermission?: AcpPermissionHandler - onRequest?: ( - method: string, - params: unknown, - context: AcpRequestContext - ) => unknown | Promise + onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown onDiagnostic?: (message: string) => void onClose?: (error: Error) => void } @@ -257,11 +253,7 @@ export class AcpSessionRuntime { return parsed.data } - private handleRequest( - method: string, - params: unknown, - context: AcpRequestContext - ): unknown | Promise { + private handleRequest(method: string, params: unknown, context: AcpRequestContext): unknown { if (method !== 'session/request_permission') { return this.options.onRequest?.(method, params, context) } From 0b95df2374f4e2729876711083c8cde486c55aab Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:06:00 -0700 Subject: [PATCH 026/269] Apply provider launch environment and caller teardown labels --- src/main/codex/codex-app-server-connection.ts | 8 ++----- .../provider-process-supervisor.test.ts | 21 +++++++++++++++++- .../provider-process-supervisor.ts | 6 ++++- .../provider-process-teardown.test.ts | 22 +++++++++++++++++++ .../provider-process-teardown.ts | 9 ++++---- 5 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index cae5a81aa59d..298fa4428324 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -52,17 +52,13 @@ export async function openCodexAppServerConnection( handlers: CodexAppServerConnectionHandlers = {}, spawnImpl: typeof spawnProcess = spawnProcess ): Promise { - const childEnv: NodeJS.ProcessEnv = { ...process.env, ...launch.env } - for (const key of launch.envToDelete ?? []) { - delete childEnv[key] - } - const spawnSpec = createProviderSpawnSpec(launch, childEnv, process.platform) + const spawnSpec = createProviderSpawnSpec(launch, process.env, process.platform) const child = spawnImpl(spawnSpec) function terminateProcessTree(): Promise { // The supervisor and provider own separate POSIX groups so the supervisor can prove the // provider group empty before relaying its exit. Forced wrapper teardown uses descendant proof. - return terminateProviderProcessTree(child) + return terminateProviderProcessTree(child, { site: 'codex-app-server-teardown' }) } let stderrTail = '' diff --git a/src/main/provider-process/provider-process-supervisor.test.ts b/src/main/provider-process/provider-process-supervisor.test.ts index c65f5518f3f3..dcca6feeec05 100644 --- a/src/main/provider-process/provider-process-supervisor.test.ts +++ b/src/main/provider-process/provider-process-supervisor.test.ts @@ -65,10 +65,29 @@ describe('structured provider supervision', () => { expect(createProviderSpawnSpec(launch, { PATH: '/bin' }, 'win32')).toEqual({ program: '/opt/codex', args: ['app-server', '--flag'], - env: { PATH: '/bin' }, + env: { PATH: '/bin', CODEX_HOME: '/tmp/codex' }, cwd: '/work/repo', detached: false, supervised: false }) }) + + it.each(['win32', 'darwin', 'linux'] as const)( + 'applies launch environment overrides and deletions on %s', + (platform) => { + const baseEnv = { PATH: '/bin', AGENT_HOME: '/inherited', PARENT_AGENT: 'parent' } + const overlay = { AGENT_HOME: '/pinned', LAUNCH_ONLY: 'added', PARENT_AGENT: 'overlay' } + const spec = createProviderSpawnSpec( + { command: 'provider', args: [], env: overlay, envToDelete: ['PARENT_AGENT'] }, + baseEnv, + platform + ) + + expect(spec.env).toMatchObject({ PATH: '/bin', AGENT_HOME: '/pinned', LAUNCH_ONLY: 'added' }) + expect(spec.env).not.toHaveProperty('PARENT_AGENT') + expect(baseEnv.PARENT_AGENT).toBe('parent') + expect(baseEnv.AGENT_HOME).toBe('/inherited') + expect(overlay.PARENT_AGENT).toBe('overlay') + } + ) }) diff --git a/src/main/provider-process/provider-process-supervisor.ts b/src/main/provider-process/provider-process-supervisor.ts index 459c379b6881..a446167b2f05 100644 --- a/src/main/provider-process/provider-process-supervisor.ts +++ b/src/main/provider-process/provider-process-supervisor.ts @@ -175,7 +175,7 @@ export function supervisedPosixLaunch( export function createProviderSpawnSpec( launch: ProviderProcessLaunch, - childEnv: NodeJS.ProcessEnv, + baseEnv: NodeJS.ProcessEnv, platform: NodeJS.Platform ): { program: string @@ -186,6 +186,10 @@ export function createProviderSpawnSpec( /** The child is the supervisor, whose SIGTERM stops the provider and then itself. */ supervised: boolean } { + const childEnv: NodeJS.ProcessEnv = { ...baseEnv, ...launch.env } + for (const key of launch.envToDelete ?? []) { + delete childEnv[key] + } const supervisor = platform === 'win32' ? null : supervisedPosixLaunch(launch, childEnv) return { program: supervisor?.command ?? launch.command, diff --git a/src/main/provider-process/provider-process-teardown.test.ts b/src/main/provider-process/provider-process-teardown.test.ts index 60826a269c5f..d5fdcce6afcf 100644 --- a/src/main/provider-process/provider-process-teardown.test.ts +++ b/src/main/provider-process/provider-process-teardown.test.ts @@ -27,6 +27,7 @@ describe('terminateProviderProcessTree', () => { const terminateWindowsTree = vi.fn(() => release.promise) const teardown = terminateProviderProcessTree(target, { + site: 'codex-app-server-teardown', platform: 'win32', terminateWindowsTree }) @@ -38,12 +39,25 @@ describe('terminateProviderProcessTree', () => { expect(target.kill).toHaveBeenCalledWith('SIGKILL') }) + it('passes a non-Codex diagnostic label to Windows teardown', async () => { + const terminateWindowsTree = vi.fn(async () => undefined) + + await terminateProviderProcessTree(child(), { + site: 'provider-test-teardown', + platform: 'win32', + terminateWindowsTree + }) + + expect(terminateWindowsTree).toHaveBeenCalledWith(1234, { site: 'provider-test-teardown' }) + }) + it('waits for an owned POSIX snapshot before killing the wrapper', async () => { const target = child() const snapshot = { rootPgid: 1234, descendants: [], capturedAtMs: 1 } const release = Promise.withResolvers() const teardown = terminateProviderProcessTree(target, { + site: 'codex-app-server-teardown', platform: 'darwin', captureDescendants: async () => snapshot, terminateDescendants: () => release.promise @@ -63,6 +77,7 @@ describe('terminateProviderProcessTree', () => { await expect( terminateProviderProcessTree(target, { + site: 'provider-test-teardown', platform: 'darwin', dedicatedProcessGroup: true, captureDescendants, @@ -73,6 +88,9 @@ describe('terminateProviderProcessTree', () => { expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL') expect(captureDescendants).not.toHaveBeenCalled() expect(target.kill).not.toHaveBeenCalled() + expect(findSelfInitiatedTreeKills(Date.now())).toEqual([ + expect.objectContaining({ site: 'provider-test-teardown', scope: 'posix-process-group' }) + ]) }) it('keeps the dedicated-group wrapper reachable when signalling is unproven', async () => { @@ -80,6 +98,7 @@ describe('terminateProviderProcessTree', () => { await expect( terminateProviderProcessTree(target, { + site: 'codex-app-server-teardown', platform: 'linux', dedicatedProcessGroup: true, signalProcessGroup: () => { @@ -103,6 +122,7 @@ describe('terminateProviderProcessTree', () => { await expect( terminateProviderProcessTree(target, { + site: 'codex-app-server-teardown', platform: 'darwin', captureDescendants: async () => ({ rootPgid: UNREACHABLE_PGID, @@ -123,6 +143,7 @@ describe('terminateProviderProcessTree', () => { await expect( terminateProviderProcessTree(target, { + site: 'codex-app-server-teardown', platform: 'darwin', captureDescendants: async () => ({ rootPgid: 1234, descendants: [], capturedAtMs: 1 }), terminateDescendants: async () => true, @@ -152,6 +173,7 @@ describe('terminateProviderProcessTree', () => { const results = await Promise.all( targets.map((target) => terminateProviderProcessTree(target, { + site: 'codex-app-server-teardown', platform: 'linux', dedicatedProcessGroup: true, captureDescendants, diff --git a/src/main/provider-process/provider-process-teardown.ts b/src/main/provider-process/provider-process-teardown.ts index de55788ae5f2..19e5b9b967f3 100644 --- a/src/main/provider-process/provider-process-teardown.ts +++ b/src/main/provider-process/provider-process-teardown.ts @@ -9,6 +9,7 @@ const activeTeardowns = new WeakMap>() type TeardownChild = Pick export type ProviderProcessTeardownDeps = { + site: string platform?: NodeJS.Platform dedicatedProcessGroup?: boolean captureDescendants?: (rootPid: number) => Promise @@ -30,7 +31,7 @@ function terminateDedicatedPosixGroup(rootPid: number, deps: ProviderProcessTear // Outside the try: that catch is the ESRCH contract, not a breadcrumb handler. recordSelfInitiatedTreeKill({ pid: rootPid, - site: 'codex-app-server-teardown', + site: deps.site, scope: 'posix-process-group' }) return true @@ -72,7 +73,7 @@ async function terminatePosixTree( // already-gone contract, not a breadcrumb handler. recordSelfInitiatedTreeKill({ pid: snapshot.rootPgid, - site: 'codex-app-server-teardown', + site: deps.site, scope: 'posix-process-group' }) } @@ -97,7 +98,7 @@ async function terminateOnce( } if ((deps.platform ?? process.platform) === 'win32') { const terminate = deps.terminateWindowsTree ?? terminateWindowsProcessTree - await terminate(rootPid, { site: 'codex-app-server-teardown' }) + await terminate(rootPid, { site: deps.site }) // taskkill owns the tree; this preserves the prior direct-child fallback when it fails. child.kill('SIGKILL') return true @@ -110,7 +111,7 @@ async function terminateOnce( export function terminateProviderProcessTree( child: TeardownChild, - deps: ProviderProcessTeardownDeps = {} + deps: ProviderProcessTeardownDeps ): Promise { const key = child const active = activeTeardowns.get(key) From e30b885444e9ad1093d27a34aec68d974fe22015 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:12:45 -0700 Subject: [PATCH 027/269] Keep ACP waits caller-owned and preserve protocol extensions --- .gitattributes | 3 + .oxlintrc.json | 1 + config/scripts/acp/generate-protocol.mjs | 76 +- src/main/acp/acp-errors.ts | 8 +- src/main/acp/acp-incoming-requests.ts | 50 +- src/main/acp/acp-json-rpc-peer.test.ts | 40 +- src/main/acp/acp-json-rpc-peer.ts | 109 +- src/main/acp/acp-permission-requests.ts | 2 +- src/main/acp/acp-session-lifecycle.test.ts | 281 ++++ .../acp/acp-session-notifications.test.ts | 80 + src/main/acp/acp-session-runtime.test.ts | 78 +- src/main/acp/acp-session-runtime.ts | 91 +- src/main/acp/acp-session-setup.ts | 54 +- .../acp/generated/acp-protocol.generated.ts | 1390 +++++++++++++++++ .../acp/generated/client-capabilities.gen.ts | 113 -- src/main/acp/generated/diff.gen.ts | 126 -- src/main/acp/generated/license.gen.ts | 197 --- src/main/acp/generated/mcp-server-acp.gen.ts | 116 -- .../generated/nes-context-capabilities.gen.ts | 124 -- src/main/acp/generated/plan-update.gen.ts | 103 -- .../acp/generated/protocol-version.gen.ts | 93 -- src/main/acp/generated/protocol.gen.ts | 14 - .../generated/providers-capabilities.gen.ts | 87 -- .../generated/session-config-option.gen.ts | 113 -- src/main/acp/generated/session-update.gen.ts | 132 -- .../set-session-model-request.gen.ts | 121 -- .../generated/text-resource-contents.gen.ts | 129 -- .../wait-for-terminal-exit-request.gen.ts | 30 - 28 files changed, 2025 insertions(+), 1736 deletions(-) create mode 100644 src/main/acp/acp-session-lifecycle.test.ts create mode 100644 src/main/acp/acp-session-notifications.test.ts create mode 100644 src/main/acp/generated/acp-protocol.generated.ts delete mode 100644 src/main/acp/generated/client-capabilities.gen.ts delete mode 100644 src/main/acp/generated/diff.gen.ts delete mode 100644 src/main/acp/generated/license.gen.ts delete mode 100644 src/main/acp/generated/mcp-server-acp.gen.ts delete mode 100644 src/main/acp/generated/nes-context-capabilities.gen.ts delete mode 100644 src/main/acp/generated/plan-update.gen.ts delete mode 100644 src/main/acp/generated/protocol-version.gen.ts delete mode 100644 src/main/acp/generated/protocol.gen.ts delete mode 100644 src/main/acp/generated/providers-capabilities.gen.ts delete mode 100644 src/main/acp/generated/session-config-option.gen.ts delete mode 100644 src/main/acp/generated/session-update.gen.ts delete mode 100644 src/main/acp/generated/set-session-model-request.gen.ts delete mode 100644 src/main/acp/generated/text-resource-contents.gen.ts delete mode 100644 src/main/acp/generated/wait-for-terminal-exit-request.gen.ts diff --git a/.gitattributes b/.gitattributes index e0e0282b69f8..d995215b26cf 100644 --- a/.gitattributes +++ b/.gitattributes @@ -88,3 +88,6 @@ /mobile/web-entry/**/*.otf -text /mobile/web-entry/**/*.woff -text /mobile/web-entry/**/*.woff2 -text + +# Generated ACP schemas are checked byte-for-byte against formatted output. +/src/main/acp/generated/*.generated.ts linguist-generated=true text eol=lf diff --git a/.oxlintrc.json b/.oxlintrc.json index 7a0753c06e27..fec10ce318ec 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -208,6 +208,7 @@ } ], "ignorePatterns": [ + "src/main/acp/generated/acp-protocol.generated.ts", "src/shared/rpc-contract/rpc-params-catalog.generated.ts", "**/node_modules", "**/dist", diff --git a/config/scripts/acp/generate-protocol.mjs b/config/scripts/acp/generate-protocol.mjs index db3de9cf5e31..9e154fde5052 100644 --- a/config/scripts/acp/generate-protocol.mjs +++ b/config/scripts/acp/generate-protocol.mjs @@ -60,6 +60,7 @@ const roots = [ 'SetSessionModeResponse', 'SetSessionModelRequest', 'SetSessionModelResponse', + 'SessionModelState', 'SetSessionConfigOptionRequest', 'SetSessionConfigOptionResponse', 'ReadTextFileRequest', @@ -196,72 +197,17 @@ function expression(value) { return result } -// Pack in dependency order so generated imports stay acyclic and each module stays small. -const groups = [] -let group = [] -let length = 0 -for (const name of ordered) { - const body = `export const ${name}Schema = ${expression(definitions[name])}\nexport type ${name} = z.infer\n` - if (length + body.length > 4300 && group.length) { - groups.push(group) - group = [] - length = 0 - } - group.push({ name, body }) - length += body.length -} -if (group.length) { - groups.push(group) -} -const filenames = new Map() -for (const entries of groups) { - const filename = entries[0].name.replace( - /[A-Z]/g, - (c, offset) => `${offset ? '-' : ''}${c.toLowerCase()}` - ) - for (const { name } of entries) { - filenames.set(name, `${filename}.gen.ts`) - } -} - -const generated = new Map() -for (const entries of groups) { - const file = filenames.get(entries[0].name) - const imports = new Map() - for (const { body } of entries) { - for (const dependency of [...body.matchAll(/\b(\w+)Schema\b/g)].map((match) => match[1])) { - const source = filenames.get(dependency) - if (source === file) { - continue - } - if (!imports.has(source)) { - imports.set(source, new Set()) - } - imports.get(source).add(`${dependency}Schema`) - } - } - generated.set( - file, - `${header}import { z } from 'zod'\n${[...imports] +const generated = new Map([ + [ + 'acp-protocol.generated.ts', + `${header}/*\n${license.trim()}\n*/\nimport { z } from 'zod'\nexport const ACP_SCHEMA_RELEASE = '${release}'\nexport const ACP_LEGACY_MODEL_SCHEMA_RELEASE = '${legacyRelease}'\nexport const ACP_PROTOCOL_VERSION = 1\n${ordered .map( - ([source, names]) => `import { ${[...names].join(',')} } from './${source.slice(0, -3)}'\n` + (name) => + `export const ${name}Schema = ${expression(definitions[name])}\nexport type ${name} = z.infer\n` ) - .join('')}${entries.map((entry) => entry.body).join('\n')}` - ) -} -generated.set( - 'protocol.gen.ts', - `${ - header + - [...new Set(filenames.values())] - .map((file) => `export * from './${file.slice(0, -3)}'`) - .join('\n') - }\n` -) -generated.set( - 'license.gen.ts', - `${header}/*\n${license.trim()}\n*/\nexport const ACP_SCHEMA_RELEASE = '${release}'\nexport const ACP_LEGACY_MODEL_SCHEMA_RELEASE = '${legacyRelease}'\nexport const ACP_PROTOCOL_VERSION = 1\n` -) + .join('\n')}` + ] +]) // Use the repository formatter without spawning a platform-dependent executable shim. const { format } = await import('oxfmt') @@ -286,7 +232,7 @@ for (const [file, source] of generated) { } } for (const file of await readdir(output)) { - if (file.endsWith('.gen.ts') && !generated.has(file)) { + if ((file.endsWith('.gen.ts') || file.endsWith('.generated.ts')) && !generated.has(file)) { if (check || !(await readFile(resolve(output, file), 'utf8')).startsWith(header)) { throw new Error(`Unexpected generated file: ${file}`) } diff --git a/src/main/acp/acp-errors.ts b/src/main/acp/acp-errors.ts index 732db5c26cfc..41aff9694e2e 100644 --- a/src/main/acp/acp-errors.ts +++ b/src/main/acp/acp-errors.ts @@ -1,3 +1,5 @@ +import type { AuthMethod } from './generated/acp-protocol.generated' + export class AcpRpcError extends Error { constructor( readonly code: number, @@ -10,7 +12,11 @@ export class AcpRpcError extends Error { } export class AcpAuthRequiredError extends AcpRpcError { - constructor(message: string, data?: unknown) { + constructor( + message: string, + data?: unknown, + readonly authMethods: AuthMethod[] = [] + ) { super(-32000, message, data) this.name = 'AcpAuthRequiredError' } diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts index ffdeae1a0992..b565c00c207a 100644 --- a/src/main/acp/acp-incoming-requests.ts +++ b/src/main/acp/acp-incoming-requests.ts @@ -1,7 +1,7 @@ import { AcpRpcError } from './acp-errors' import type { AcpJsonRpcMessage, AcpPeerHandlers } from './acp-json-rpc-peer' -type OpenRequest = { controller: AbortController; abandon: () => void } +type OpenRequest = { method: string; controller: AbortController; abandon: () => void } export class AcpIncomingRequests { private readonly open = new Map() @@ -11,7 +11,7 @@ export class AcpIncomingRequests { private readonly send: (message: AcpJsonRpcMessage) => Promise, private readonly onFailure: (error: Error) => void, private readonly capacity: number, - private readonly timeoutMs: number + private readonly diagnose: (message: string) => void ) {} close(error: Error): void { @@ -22,50 +22,58 @@ export class AcpIncomingRequests { this.open.clear() } + cancel(exceptMethod?: string): void { + for (const [id, request] of this.open) { + if (request.method === exceptMethod) { + continue + } + const error = new AcpRpcError(-32800, 'Request cancelled') + this.open.delete(id) + request.controller.abort(error) + request.abandon() + void this.sendError(id, error) + } + } + handle(id: string | number | null, method: string, params: unknown): void { - if (this.open.has(id) || this.open.size >= this.capacity) { - void this.sendError( - id, - new AcpRpcError(-32600, 'Duplicate request id or incoming request capacity exceeded') - ) + if (this.open.has(id)) { + this.diagnose('Ignored duplicate ACP incoming request id') + return + } + if (this.open.size >= this.capacity) { + void this.sendError(id, new AcpRpcError(-32603, 'ACP incoming request capacity exceeded')) return } const controller = new AbortController() - let timer: ReturnType | undefined let abandon = (): void => {} - const deadline = new Promise((_resolve, reject) => { + const abandoned = new Promise((_resolve, reject) => { abandon = () => reject(controller.signal.reason) - timer = setTimeout( - () => reject(new AcpRpcError(-32001, `ACP client request timed out: ${method}`)), - this.timeoutMs - ) }) - this.open.set(id, { controller, abandon }) + this.open.set(id, { method, controller, abandon }) const retire = (): void => { - clearTimeout(timer) if (this.open.get(id)?.controller === controller) { this.open.delete(id) } } void Promise.race([ - deadline, + abandoned, Promise.resolve().then(() => { if (controller.signal.aborted) { return undefined } - return this.handler?.(method, params, { id, signal: controller.signal }) + if (!this.handler) { + throw new AcpRpcError(-32601, `Unknown ACP client method: ${method}`) + } + return this.handler(method, params, { id, signal: controller.signal }) }) ]) .then(async (result) => { if (controller.signal.aborted) { return } - if (result === undefined) { - throw new AcpRpcError(-32601, `Unknown ACP client method: ${method}`) - } // The agent may reuse the id as soon as it reads the response. retire() - await this.send({ jsonrpc: '2.0', id, result }) + await this.send({ jsonrpc: '2.0', id, result: result ?? null }) }) .catch(async (error) => { if (controller.signal.aborted) { diff --git a/src/main/acp/acp-json-rpc-peer.test.ts b/src/main/acp/acp-json-rpc-peer.test.ts index 1019049d51fb..9ead2e2ddc0c 100644 --- a/src/main/acp/acp-json-rpc-peer.test.ts +++ b/src/main/acp/acp-json-rpc-peer.test.ts @@ -51,7 +51,7 @@ describe('ACP JSON-RPC peer', () => { if (method === '_crash') { throw new Error('Handler failed') } - return undefined + throw new AcpRpcError(-32601, 'Unknown method') } }) expect(await agent.request('missing', '_missing', {})).toMatchObject({ @@ -102,13 +102,14 @@ describe('ACP JSON-RPC peer', () => { expect(diagnostics).toContain('Ignored invalid ACP JSON-RPC envelope') }) - it('ignores malformed response envelopes without settling pending requests', async () => { + it('rejects malformed matching responses instead of leaving calls pending', async () => { const { peer, agent } = fixture() - const pending = peer.request('wait', {}) + const rejected = expect(peer.request('wait', {})).rejects.toMatchObject({ code: -32603 }) agent.stdout.write('{"jsonrpc":"2.0","id":1}\n') - agent.stdout.write('{"jsonrpc":"2.0","id":1,"result":"x","error":{"code":1,"message":"y"}}\n') - agent.stdout.write('{"jsonrpc":"2.0","id":1,"result":"ok"}\n') - expect(await pending).toBe('ok') + await rejected + const contradictory = expect(peer.request('wait', {})).rejects.toMatchObject({ code: -32603 }) + agent.stdout.write('{"jsonrpc":"2.0","id":2,"result":"x","error":{"code":1,"message":"y"}}\n') + await contradictory }) it('rejects all pending calls on exit and stops accepting requests', async () => { @@ -138,30 +139,29 @@ describe('ACP JSON-RPC peer', () => { expect(agent.frames.map((frame) => frame.method)).toEqual(['wait', 'next']) }) - it('expires unanswered incoming requests and aborts their hooks', async () => { + it('bounds incoming requests without expiring user decisions', async () => { vi.useFakeTimers() - const signals: AbortSignal[] = [] + const signal = deferred() + const answer = deferred() const { agent } = fixture( { onRequest: (_method, _params, context) => { - signals.push(context.signal) - return new Promise(() => {}) + signal.resolve(context.signal) + return answer.promise } }, - { maxIncomingRequests: 1, incomingRequestTimeoutMs: 20 } + { maxIncomingRequests: 1, requestTimeoutMs: 20 } ) const first = agent.request('first', '_question', {}) - await vi.advanceTimersByTimeAsync(0) + const context = await signal.promise expect(await agent.request('overflow', '_question', {})).toMatchObject({ - error: { code: -32600 } + error: { code: -32603 } }) - await vi.advanceTimersByTimeAsync(20) - expect(await first).toMatchObject({ error: { code: -32001 } }) - expect(signals[0].aborted).toBe(true) - const next = agent.request('next', '_question', {}) - await vi.advanceTimersByTimeAsync(20) - expect(await next).toMatchObject({ error: { code: -32001 } }) - expect(signals).toHaveLength(2) + await vi.advanceTimersByTimeAsync(120_001) + expect(context.aborted).toBe(false) + answer.resolve({ answer: true }) + expect(await first).toMatchObject({ result: { answer: true } }) + expect(await agent.request('next', '_question', {})).toMatchObject({ result: { answer: true } }) }) it('aborts an in-flight incoming hook on close and never writes its late response', async () => { diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index f48a1b63ad01..08df74ef89ae 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -1,33 +1,33 @@ import type { Readable, Writable } from 'node:stream' import { z } from 'zod' +import { isSafeTimerDelayMs } from '../../shared/timer-delay' import { createIncrementalNdjsonFramer, encodeNdjson } from '../../shared/main-process-ndjson-framer' -import { - AcpAuthRequiredError, - AcpConnectionClosedError, - AcpRequestTimeoutError, - AcpRpcError -} from './acp-errors' +import { AcpConnectionClosedError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' import { AcpIncomingRequests } from './acp-incoming-requests' import { AcpWriteQueue } from './acp-write-queue' import { detachAcpStreamErrorHandler } from './acp-stdio-error-boundary' const idSchema = z.union([z.string(), z.number(), z.null()]) +const errorSchema = z.object({ + code: z.number().int(), + message: z.string(), + data: z.unknown().optional() +}) const envelopeSchema = z.looseObject({ jsonrpc: z.literal('2.0'), id: idSchema.optional(), method: z.string().optional(), params: z.unknown().optional(), result: z.unknown().optional(), - error: z - .object({ code: z.number().int(), message: z.string(), data: z.unknown().optional() }) - .optional() + error: z.unknown().optional() }) export type AcpJsonRpcMessage = z.infer export type AcpRequestContext = { id: string | number | null; signal: AbortSignal } export type AcpPeerHandlers = { + // Void means handled; unsupported methods must throw AcpRpcError(-32601). onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown onNotification?: (method: string, params: unknown) => void onDiagnostic?: (message: string) => void @@ -38,13 +38,13 @@ export type AcpPeerOptions = { maxQueuedWriteBytes?: number maxPendingRequests?: number maxIncomingRequests?: number - requestTimeoutMs?: number - incomingRequestTimeoutMs?: number + // Caller-selected outgoing deadlines only; omitted or null means unlimited. + requestTimeoutMs?: number | null } type Pending = { resolve: (value: unknown) => void reject: (error: Error) => void - timer: ReturnType + timer?: ReturnType } export class AcpJsonRpcPeer { @@ -57,7 +57,7 @@ export class AcpJsonRpcPeer { private readonly maxLineBytes: number private readonly maxPending: number private readonly maxIncoming: number - private readonly timeoutMs: number + private readonly timeoutMs: number | null constructor( private readonly input: Readable, @@ -68,7 +68,7 @@ export class AcpJsonRpcPeer { this.maxLineBytes = bounded(options.maxLineBytes, 16 * 1024 * 1024) this.maxPending = bounded(options.maxPendingRequests, 128) this.maxIncoming = bounded(options.maxIncomingRequests, 128) - this.timeoutMs = bounded(options.requestTimeoutMs, 120_000) + this.timeoutMs = requestTimeout(options.requestTimeoutMs) this.writer = new AcpWriteQueue( output, bounded(options.maxQueuedWriteBytes, 32 * 1024 * 1024), @@ -79,7 +79,7 @@ export class AcpJsonRpcPeer { (message) => this.send(message), (error) => this.close(error), this.maxIncoming, - bounded(options.incomingRequestTimeoutMs, this.timeoutMs) + (message) => this.diagnose(message) ) this.framer = createIncrementalNdjsonFramer( (record) => this.dispatch(record), @@ -103,25 +103,37 @@ export class AcpJsonRpcPeer { return this.terminalError !== undefined } - request(method: string, params: unknown, options: { timeoutMs?: number } = {}): Promise { + request( + method: string, + params: unknown, + options: { timeoutMs?: number | null } = {} + ): Promise { if (this.terminalError) { return Promise.reject(this.terminalError) } if (this.pending.size >= this.maxPending) { return Promise.reject(new Error('ACP pending request capacity exceeded')) } + let timeoutMs: number | null + try { + timeoutMs = requestTimeout( + options.timeoutMs === undefined ? this.timeoutMs : options.timeoutMs + ) + } catch (error) { + return Promise.reject(error) + } const id = this.nextId++ const controller = new AbortController() return new Promise((resolve, reject) => { - const timer = setTimeout( - () => { - this.pending.delete(id) - const error = new AcpRequestTimeoutError(method) - controller.abort(error) - reject(error) - }, - bounded(options.timeoutMs, this.timeoutMs) - ) + const timer = + timeoutMs === null + ? undefined + : setTimeout(() => { + this.pending.delete(id) + const error = new AcpRequestTimeoutError(method) + controller.abort(error) + reject(error) + }, timeoutMs) this.pending.set(id, { resolve, reject, timer }) void this.send({ jsonrpc: '2.0', id, method, params }, controller.signal).catch((error) => { const pending = this.pending.get(id) @@ -139,6 +151,10 @@ export class AcpJsonRpcPeer { return this.send({ jsonrpc: '2.0', method, params }) } + cancelIncomingRequests(exceptMethod?: string): void { + this.incoming.cancel(exceptMethod) + } + close(error: Error = new AcpConnectionClosedError()): void { if (this.terminalError) { return @@ -194,6 +210,15 @@ export class AcpJsonRpcPeer { } } + private rejectInvalidResponse(id: number, raw: unknown): void { + const pending = this.pending.get(id) + if (pending) { + this.pending.delete(id) + clearTimeout(pending.timer) + pending.reject(new AcpRpcError(-32603, 'Invalid ACP response envelope', raw)) + } + } + private dispatch(record: unknown): void { if (this.closed) { return @@ -201,6 +226,12 @@ export class AcpJsonRpcPeer { const parsed = envelopeSchema.safeParse(record) if (!parsed.success) { this.diagnose('Ignored invalid ACP JSON-RPC envelope') + const response = z + .object({ id: z.number(), method: z.undefined().optional() }) + .safeParse(record) + if (response.success) { + this.rejectInvalidResponse(response.data.id, record) + } return } const frame = parsed.data @@ -222,6 +253,9 @@ export class AcpJsonRpcPeer { } if ('result' in frame === 'error' in frame) { this.diagnose('Ignored invalid ACP response') + if (typeof frame.id === 'number') { + this.rejectInvalidResponse(frame.id, record) + } return } if (typeof frame.id !== 'number') { @@ -233,12 +267,15 @@ export class AcpJsonRpcPeer { } this.pending.delete(frame.id) clearTimeout(pending.timer) - if (frame.error) { - pending.reject( - frame.error.code === -32000 - ? new AcpAuthRequiredError(frame.error.message, frame.error.data) - : new AcpRpcError(frame.error.code, frame.error.message, frame.error.data) - ) + if ('error' in frame) { + const parsedError = errorSchema.safeParse(frame.error) + if (!parsedError.success) { + this.diagnose('Invalid ACP error response') + pending.reject(new AcpRpcError(-32603, 'Invalid ACP error response', frame.error)) + } else { + const error = parsedError.data + pending.reject(new AcpRpcError(error.code, error.message, error.data)) + } } else { pending.resolve(frame.result) } @@ -254,3 +291,13 @@ function bounded(value: number | undefined, fallback: number): number { } return value } + +function requestTimeout(value: number | null | undefined): number | null { + if (value == null) { + return null + } + if (!isSafeTimerDelayMs(value) || value <= 0) { + throw new Error('ACP timeouts must be positive finite timer durations') + } + return value +} diff --git a/src/main/acp/acp-permission-requests.ts b/src/main/acp/acp-permission-requests.ts index 120f896be2b6..4701359d11b1 100644 --- a/src/main/acp/acp-permission-requests.ts +++ b/src/main/acp/acp-permission-requests.ts @@ -4,7 +4,7 @@ import { RequestPermissionResponseSchema, type RequestPermissionRequest, type RequestPermissionResponse -} from './generated/protocol.gen' +} from './generated/acp-protocol.generated' export type AcpPermissionHandler = ( request: RequestPermissionRequest, diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts new file mode 100644 index 000000000000..ec79badfbb5f --- /dev/null +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -0,0 +1,281 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AcpJsonRpcPeer } from './acp-json-rpc-peer' +import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' +import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' +import { AcpConnectionClosedError, AcpRequestTimeoutError } from './acp-errors' +import type { RequestPermissionResponse } from './generated/acp-protocol.generated' + +const opened: { close: () => void }[] = [] +const startOptions = { cwd: '/runtime/project', mcpServers: [] } +const prompt = [{ type: 'text', text: 'hello' }] as const +const permission = { + sessionId: 'session-1', + toolCall: { toolCallId: 'tool-1', title: 'Edit file' }, + options: [{ optionId: 'allow', name: 'Allow once', kind: 'allow_once' }] +} +function fixture(options: AcpSessionRuntimeOptions = {}) { + const agent = new AcpScriptedAgent() + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, options) + opened.push(agent, runtime) + agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1 })) + agent.on('session/new', (frame) => agent.reply(frame, { sessionId: 'session-1' })) + return { agent, runtime } +} +afterEach(() => { + opened + .splice(0) + .toReversed() + .forEach((resource) => resource.close()) + vi.useRealTimers() +}) + +describe('ACP caller-owned waits', () => { + it('keeps permission requests open beyond two minutes and delivers the late user decision', async () => { + vi.useFakeTimers() + const entered = deferred() + const decision = deferred() + const { agent, runtime } = fixture({ + onPermission: (_request, context) => { + entered.resolve(context.signal) + return decision.promise + } + }) + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const pending = runtime.prompt([...prompt]) + const rejected = expect(pending).rejects.toBeInstanceOf(AcpConnectionClosedError) + let answered = false + const response = agent.request('permission', 'session/request_permission', permission) + void response.then(() => { + answered = true + }) + const signal = await entered.promise + await vi.advanceTimersByTimeAsync(120_001) + expect(signal.aborted).toBe(false) + expect(answered).toBe(false) + decision.resolve({ outcome: { outcome: 'selected', optionId: 'allow' } }) + expect(await response).toMatchObject({ + result: { outcome: { outcome: 'selected', optionId: 'allow' } } + }) + runtime.close() + await rejected + }) + + it('lets a streaming turn run beyond thirty minutes and keeps the session usable', async () => { + vi.useFakeTimers() + const onClose = vi.fn() + const event = vi.fn() + const { agent, runtime } = fixture({ onClose }) + runtime.subscribe(event) + agent.on('session/prompt', () => {}) + agent.on('session/set_mode', (frame) => agent.reply(frame, {})) + await runtime.start(startOptions) + let finished = false + const pending = runtime.prompt([...prompt]) + void pending.then(() => { + finished = true + }) + await vi.advanceTimersByTimeAsync(30 * 60_000 + 1) + agent.notify('session/update', { + sessionId: 'session-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Still working' } + } + }) + expect(event).toHaveBeenCalledTimes(1) + expect(finished).toBe(false) + expect(onClose).not.toHaveBeenCalled() + expect(agent.frames.some((frame) => frame.method === 'session/cancel')).toBe(false) + await expect(runtime.setMode('plan')).resolves.toEqual({}) + const frame = agent.frames.find((frame) => frame.method === 'session/prompt') + expect(frame).toBeDefined() + if (frame) { + agent.reply(frame, { stopReason: 'end_turn' }) + } + await expect(pending).resolves.toEqual({ stopReason: 'end_turn' }) + }) + + it.each([ + { id: 'key', name: 'API key', type: 'env_var', vars: [{ name: 'X_API_KEY' }] }, + { id: 'browser', name: 'Browser login', type: 'agent' }, + { id: 'default', name: 'Default login' }, + { id: 'terminal', name: 'Terminal login', type: 'terminal' } + ])('surfaces $id authentication without choosing it for the caller', async (method) => { + const { agent, runtime } = fixture() + agent.on('initialize', (frame) => + agent.reply(frame, { protocolVersion: 1, authMethods: [method] }) + ) + agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required')) + await expect(runtime.start(startOptions)).rejects.toMatchObject({ + name: 'AcpAuthRequiredError', + authMethods: [method] + }) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize', 'session/new']) + }) + + it.each(['prompt', 'cancel', 'setMode', 'setModel', 'setConfigOption'] as const)( + 'rejects %s before start through its promise', + async (method) => { + const { runtime } = fixture() + let pending: Promise | undefined + expect(() => { + switch (method) { + case 'prompt': + pending = runtime.prompt([...prompt]) + break + case 'cancel': + pending = runtime.cancel() + break + case 'setMode': + pending = runtime.setMode('plan') + break + case 'setModel': + pending = runtime.setModel('model') + break + case 'setConfigOption': + pending = runtime.setConfigOption('thinking', 'high') + break + } + }).not.toThrow() + await expect(pending).rejects.toThrow('ACP session has not started') + } + ) + + it('answers a handled void vendor request with null', async () => { + const handler = vi.fn() + const { agent } = fixture({ onRequest: handler }) + expect(await agent.request(7, '_vendor/ack', {})).toMatchObject({ id: 7, result: null }) + expect(handler).toHaveBeenCalledTimes(1) + }) + + it('ignores duplicate incoming ids without answering the original request', async () => { + const answer = deferred() + const handler = vi.fn(() => answer.promise) + const diagnostics: string[] = [] + const { agent } = fixture({ + onRequest: handler, + onDiagnostic: (message) => diagnostics.push(message) + }) + const original = agent.request(5, '_vendor/question', {}) + agent.send({ jsonrpc: '2.0', id: 5, method: '_vendor/question', params: {} }) + await tick() + expect(agent.frames).toEqual([]) + expect(handler).toHaveBeenCalledTimes(1) + expect(diagnostics).toContain('Ignored duplicate ACP incoming request id') + answer.resolve({ answer: true }) + expect(await original).toMatchObject({ id: 5, result: { answer: true } }) + expect(agent.frames).toHaveLength(1) + }) + + it.each([{ code: 'E1', message: 'boom' }, { code: -1, message: null }, null])( + 'rejects a malformed error reply immediately with its raw error: %j', + async (error) => { + const { agent, runtime } = fixture() + agent.on('initialize', (frame) => { + agent.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: frame.id, error })}\n`) + }) + await expect(runtime.initialize()).rejects.toMatchObject({ code: -32603, data: error }) + } + ) + + it('maps authentication-required and retries only the explicitly chosen advertised method once', async () => { + const { agent, runtime } = fixture() + const authMethods = [{ id: 'login', name: 'Login' }] + agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1, authMethods })) + agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required')) + agent.on('authenticate', (frame) => agent.reply(frame, {})) + await expect(runtime.start({ ...startOptions, authMethodId: 'login' })).rejects.toMatchObject({ + name: 'AcpAuthRequiredError', + authMethods + }) + expect(agent.frames.map((frame) => frame.method)).toEqual([ + 'initialize', + 'session/new', + 'authenticate', + 'session/new' + ]) + }) + + it('rejects overflowing peer timeouts and supports an explicit unlimited wait', async () => { + vi.useFakeTimers() + const agent = new AcpScriptedAgent() + opened.push(agent) + expect( + () => new AcpJsonRpcPeer(agent.stdout, agent.stdin, {}, { requestTimeoutMs: 3_000_000_000 }) + ).toThrow('timer durations') + const peer = new AcpJsonRpcPeer(agent.stdout, agent.stdin, {}, { requestTimeoutMs: 20 }) + opened.push(peer) + await expect(peer.request('overflow', {}, { timeoutMs: 3_000_000_000 })).rejects.toThrow( + 'timer durations' + ) + const pending = peer.request('wait', {}, { timeoutMs: null }) + const rejected = expect(pending).rejects.toBeInstanceOf(AcpConnectionClosedError) + await vi.advanceTimersByTimeAsync(3_000_000_000) + expect(peer.closed).toBe(false) + expect(agent.frames.map((frame) => frame.method)).toEqual(['wait']) + peer.close() + await rejected + }) + + it('clamps cancellation confirmation waits to the safe timer range', async () => { + vi.useFakeTimers() + const { agent, runtime } = fixture({ cancelTimeoutMs: 3_000_000_000 }) + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const rejected = expect(runtime.prompt([...prompt])).rejects.toBeInstanceOf( + AcpRequestTimeoutError + ) + const cancelled = expect(runtime.cancel()).rejects.toBeInstanceOf(AcpRequestTimeoutError) + await vi.advanceTimersByTimeAsync(1) + expect(agent.frames.at(-1)?.method).toBe('session/cancel') + await vi.advanceTimersByTimeAsync(2_147_483_646) + await Promise.all([rejected, cancelled]) + }) + + it('lets initialize and explicit authentication wait for slow first-run startup and login', async () => { + vi.useFakeTimers() + const { agent, runtime } = fixture({ peer: { requestTimeoutMs: 20 } }) + agent.on('initialize', () => {}) + const initialized = runtime.initialize() + await vi.advanceTimersByTimeAsync(120_001) + agent.reply(agent.frames[0], { protocolVersion: 1 }) + await initialized + const authenticated = runtime.authenticate('caller-chosen') + await vi.advanceTimersByTimeAsync(120_001) + agent.reply(agent.frames[1], {}) + await expect(authenticated).resolves.toEqual({}) + }) + + it('lets cancel abort vendor decision hooks with request-cancelled instead of a timeout', async () => { + const entered = deferred() + const { agent, runtime } = fixture({ + onRequest: (_method, _params, context) => { + entered.resolve(context.signal) + return new Promise(() => {}) + } + }) + agent.on('session/prompt', (frame) => + agent.on('session/cancel', () => agent.reply(frame, { stopReason: 'cancelled' })) + ) + await runtime.start(startOptions) + const pending = runtime.prompt([...prompt]) + const response = agent.request('question', '_vendor/question', {}) + const signal = await entered.promise + await runtime.cancel() + expect(signal.aborted).toBe(true) + expect(await response).toMatchObject({ error: { code: -32800 } }) + await pending + }) + + it('settles requests when the process owner closes on exit even if stdout stays open', async () => { + const { agent, runtime } = fixture() + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const rejected = expect(runtime.prompt([...prompt])).rejects.toThrow('Agent exited') + expect(agent.stdout.readableEnded).toBe(false) + runtime.close(new Error('Agent exited')) + await rejected + expect(agent.stdout.readableEnded).toBe(false) + }) +}) diff --git a/src/main/acp/acp-session-notifications.test.ts b/src/main/acp/acp-session-notifications.test.ts new file mode 100644 index 000000000000..181a7cb8dedc --- /dev/null +++ b/src/main/acp/acp-session-notifications.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AcpSessionRuntime, type AcpSessionEvent } from './acp-session-runtime' +import { AcpScriptedAgent } from './acp-scripted-agent.test-support' + +const opened: { close: () => void }[] = [] +function fixture() { + const agent = new AcpScriptedAgent() + const diagnostic = vi.fn() + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, { onDiagnostic: diagnostic }) + opened.push(agent, runtime) + agent.on('initialize', (frame) => + agent.reply(frame, { protocolVersion: 1, agentCapabilities: { vendor: true } }) + ) + agent.on('session/new', (frame) => agent.reply(frame, { sessionId: 'session-1', vendor: true })) + return { agent, runtime, diagnostic } +} +afterEach(() => + opened + .splice(0) + .toReversed() + .forEach((resource) => resource.close()) +) + +describe('ACP session update compatibility', () => { + it.each([ + ['known', { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }], + [ + 'unrecognized', + { sessionUpdate: 'tool_call', toolCallId: 't', title: 'Search', kind: 'web_search' } + ], + ['unrecognized', { sessionUpdate: 'tool_call_update', toolCallId: 't', status: 'cancelled' }], + ['unrecognized', { sessionUpdate: 'vendor_usage', tokens: 1 }], + [ + 'unrecognized', + { sessionUpdate: 'agent_message_chunk', content: { type: 'reasoning_summary', text: 'x' } } + ] + ] as const)( + 'delivers %s updates without losing their original fields (%j)', + async (kind, update) => { + const { agent, runtime, diagnostic } = fixture() + const events: AcpSessionEvent[] = [] + runtime.subscribe((event) => events.push(event)) + await runtime.start({ cwd: '/runtime/project', mcpServers: [] }) + const notification = { sessionId: 'session-1', update, vendor: 'retained' } + agent.notify('session/update', notification) + agent.notify('session/update', notification) + expect(events).toHaveLength(2) + expect(events[0]).toEqual( + kind === 'known' + ? { kind, notification } + : { kind, sessionId: 'session-1', raw: notification } + ) + expect(diagnostic).toHaveBeenCalledTimes(kind === 'known' ? 0 : 1) + } + ) + + it('rejects only envelopes missing the session id or update discriminator', async () => { + const { agent, runtime } = fixture() + const received = vi.fn() + runtime.subscribe(received) + await runtime.start({ cwd: '/runtime/project', mcpServers: [] }) + agent.notify('session/update', { update: { sessionUpdate: 'vendor' } }) + agent.notify('session/update', { sessionId: 'session-1', update: {} }) + expect(received).not.toHaveBeenCalled() + }) + + it('preserves extra session fields and exposes legacy model state as typed data', async () => { + const { agent, runtime } = fixture() + const models = { + currentModelId: 'model-1', + availableModels: [{ modelId: 'model-1', name: 'Model one' }] + } + agent.on('session/new', (frame) => + agent.reply(frame, { sessionId: 'session-1', models, vendor: true }) + ) + const started = await runtime.start({ cwd: '/runtime/project', mcpServers: [] }) + expect(started.response.models?.availableModels[0].modelId).toBe('model-1') + expect(started.response.vendor).toBe(true) + }) +}) diff --git a/src/main/acp/acp-session-runtime.test.ts b/src/main/acp/acp-session-runtime.test.ts index bdc1e0cd8256..068da3f4742f 100644 --- a/src/main/acp/acp-session-runtime.test.ts +++ b/src/main/acp/acp-session-runtime.test.ts @@ -2,16 +2,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AcpAuthRequiredError, AcpConnectionClosedError, - AcpRequestTimeoutError + AcpRequestTimeoutError, + AcpRpcError } from './acp-errors' -import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' +import { + AcpSessionRuntime, + type AcpSessionRuntimeOptions, + type AcpSessionEvent +} from './acp-session-runtime' import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' import type { AgentCapabilities, - RequestPermissionResponse, - SessionNotification -} from './generated/protocol.gen' -import { SetSessionConfigOptionRequestSchema } from './generated/protocol.gen' + RequestPermissionResponse +} from './generated/acp-protocol.generated' +import { SetSessionConfigOptionRequestSchema } from './generated/acp-protocol.generated' const opened: { runtime: AcpSessionRuntime; agent: AcpScriptedAgent }[] = [] const startOptions = { cwd: '/runtime/project', mcpServers: [] } @@ -45,7 +49,7 @@ afterEach(() => { describe('ACP session runtime', () => { it('initializes once, starts a session, streams typed updates, and completes the turn', async () => { const { runtime, agent } = fixture() - const events: SessionNotification[] = [] + const events: AcpSessionEvent[] = [] const unsubscribe = runtime.subscribe((event) => events.push(event)) const updates = [ { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello' } }, @@ -72,7 +76,9 @@ describe('ACP session runtime', () => { await Promise.all([runtime.initialize(), runtime.initialize()]) expect(await runtime.start(startOptions)).toMatchObject({ kind: 'new', sessionId: 'session-1' }) expect(await runtime.prompt([...textPrompt])).toEqual({ stopReason: 'end_turn' }) - expect(events.map((event) => event.update)).toEqual(updates) + expect( + events.map((event) => (event.kind === 'known' ? event.notification.update : event.raw.update)) + ).toEqual(updates) expect(agent.frames.filter((frame) => frame.method === 'initialize')).toHaveLength(1) expect(agent.frames[0].params).toEqual({ protocolVersion: 1, @@ -127,7 +133,11 @@ describe('ACP session runtime', () => { return { outcome: { outcome: 'selected', optionId: 'allow' } } }, onRequest: (method, params) => - method === '_vendor/question' ? { answer: params } : undefined + method === '_vendor/question' + ? { answer: params } + : (() => { + throw new AcpRpcError(-32601, 'Unknown method') + })() } ) agent.on('session/prompt', (frame) => { @@ -233,30 +243,16 @@ describe('ACP session runtime', () => { expect(agent.frames[2].params).toEqual({ methodId: 'login' }) }) - it('chooses an advertised agent authentication method when login is required', async () => { + it('leaves advertised authentication methods for the caller to choose', async () => { const { runtime, agent } = fixture() - let authenticated = false - agent.on('initialize', (frame) => - agent.reply(frame, { - protocolVersion: 1, - authMethods: [ - { id: 'terminal', name: 'Interactive login', type: 'terminal' }, - { id: 'agent', name: 'Agent login' } - ] - }) - ) - agent.on('session/new', (frame) => - authenticated - ? agent.reply(frame, { sessionId: 'session-1' }) - : agent.fail(frame, -32000, 'Login required') - ) - agent.on('authenticate', (frame) => { - expect(frame.params).toEqual({ methodId: 'agent' }) - authenticated = true - agent.reply(frame, {}) - }) - await runtime.start(startOptions) - expect(authenticated).toBe(true) + const authMethods = [ + { id: 'terminal', name: 'Interactive login', type: 'terminal' }, + { id: 'agent', name: 'Agent login' } + ] + agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1, authMethods })) + agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Login required')) + await expect(runtime.start(startOptions)).rejects.toMatchObject({ authMethods }) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize', 'session/new']) }) it('does not retry authentication indefinitely or start an interactive login', async () => { @@ -281,7 +277,9 @@ describe('ACP session runtime', () => { retry.agent.on('session/new', (frame) => retry.agent.fail(frame, -32000, 'Still requires login') ) - await expect(retry.runtime.start(startOptions)).rejects.toBeInstanceOf(AcpAuthRequiredError) + await expect( + retry.runtime.start({ ...startOptions, authMethodId: 'agent' }) + ).rejects.toBeInstanceOf(AcpAuthRequiredError) expect(retry.agent.frames.map((frame) => frame.method)).toEqual([ 'initialize', 'session/new', @@ -348,10 +346,10 @@ describe('ACP session runtime', () => { await rejected }) - it('ignores invalid updates and isolates event listener failures', async () => { + it('preserves unrecognized updates and isolates event listener failures', async () => { const diagnostics: string[] = [] const { runtime, agent } = fixture({}, { onDiagnostic: (message) => diagnostics.push(message) }) - const updates: SessionNotification[] = [] + const updates: AcpSessionEvent[] = [] runtime.subscribe(() => { throw new Error('Consumer failed') }) @@ -368,9 +366,13 @@ describe('ACP session runtime', () => { content: { type: 'text', text: 'valid', _meta: { vendor: true } } } }) - expect(updates).toHaveLength(1) - expect(updates[0].update).toMatchObject({ content: { _meta: { vendor: true } } }) - expect(diagnostics).toContain('Ignored invalid ACP session update') + expect(updates).toHaveLength(2) + expect(updates[0]).toMatchObject({ kind: 'unrecognized' }) + expect(updates[1]).toMatchObject({ + kind: 'known', + notification: { update: { content: { _meta: { vendor: true } } } } + }) + expect(diagnostics).toContain('Forwarded unrecognized ACP session update') expect(diagnostics.some((message) => message.includes('Consumer failed'))).toBe(true) }) diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 095573150ce6..bfd0e02226d7 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -1,6 +1,7 @@ import type { Readable, Writable } from 'node:stream' -import type { z } from 'zod' -import { AcpRequestTimeoutError, AcpRpcError } from './acp-errors' +import { z } from 'zod' +import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' +import { AcpAuthRequiredError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' import { AcpJsonRpcPeer, type AcpPeerOptions, type AcpRequestContext } from './acp-json-rpc-peer' import { AcpPermissionRequests, type AcpPermissionHandler } from './acp-permission-requests' import { @@ -9,7 +10,7 @@ import { type AcpSessionStartOptions } from './acp-session-setup' export type { AcpSessionStarted, AcpSessionStartOptions } from './acp-session-setup' -import { ACP_PROTOCOL_VERSION } from './generated/license.gen' +import { ACP_PROTOCOL_VERSION } from './generated/acp-protocol.generated' import { InitializeResponseSchema, AuthenticateResponseSchema, @@ -29,12 +30,19 @@ import { type SetSessionConfigOptionResponse, type SetSessionModeResponse, type SetSessionModelResponse -} from './generated/protocol.gen' +} from './generated/acp-protocol.generated' + +const updateEnvelopeSchema = z.looseObject({ + sessionId: z.string(), + update: z.looseObject({ sessionUpdate: z.string() }) +}) +export type AcpSessionEvent = + | { kind: 'known'; notification: SessionNotification } + | { kind: 'unrecognized'; sessionId: string; raw: z.infer } export type AcpSessionRuntimeOptions = { clientInfo?: InitializeRequest['clientInfo'] peer?: AcpPeerOptions - promptTimeoutMs?: number cancelTimeoutMs?: number onPermission?: AcpPermissionHandler onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown @@ -50,24 +58,21 @@ type ActivePrompt = { export class AcpSessionRuntime { private readonly peer: AcpJsonRpcPeer private readonly permissions = new AcpPermissionRequests() - private readonly listeners = new Set<(event: SessionNotification) => void>() + private readonly listeners = new Set<(event: AcpSessionEvent) => void>() private initialized?: Promise private starting?: Promise private started?: AcpSessionStarted private activePrompt?: ActivePrompt + private reportedUpdateAnomaly = false constructor( input: Readable, output: Writable, private readonly options: AcpSessionRuntimeOptions = {} ) { - for (const timeout of [options.promptTimeoutMs, options.cancelTimeoutMs]) { - if ( - timeout !== undefined && - (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 2_147_483_647) - ) { - throw new Error('ACP timeouts must be positive finite timer durations') - } + const timeout = options.cancelTimeoutMs + if (timeout !== undefined && (!Number.isSafeInteger(timeout) || timeout <= 0)) { + throw new Error('ACP timeouts must be positive finite timer durations') } this.peer = new AcpJsonRpcPeer( input, @@ -82,7 +87,7 @@ export class AcpSessionRuntime { ) } - subscribe(listener: (event: SessionNotification) => void): () => void { + subscribe(listener: (event: AcpSessionEvent) => void): () => void { this.listeners.add(listener) return () => { this.listeners.delete(listener) @@ -143,16 +148,14 @@ export class AcpSessionRuntime { return this.starting } - prompt(prompt: PromptRequest['prompt']): Promise { + async prompt(prompt: PromptRequest['prompt']): Promise { if (this.activePrompt) { - return Promise.reject(new Error('ACP prompt already in progress')) + throw new Error('ACP prompt already in progress') } const sessionId = this.sessionId() const active: ActivePrompt = { cancelling: false, - response: this.call('session/prompt', { sessionId, prompt }, PromptResponseSchema, { - timeoutMs: this.options.promptTimeoutMs ?? 30 * 60_000 - }) + response: this.call('session/prompt', { sessionId, prompt }, PromptResponseSchema) } this.activePrompt = active active.response = active.response.finally(() => { @@ -165,19 +168,23 @@ export class AcpSessionRuntime { } cancel(): Promise { - const sessionId = this.sessionId() + if (!this.started) { + return Promise.reject(new Error('ACP session has not started')) + } + const sessionId = this.started.sessionId const active = this.activePrompt if (!active) { return Promise.resolve() } active.cancelling = true this.permissions.cancel(sessionId) + this.peer.cancelIncomingRequests('session/request_permission') active.cancelPromise ??= this.cancelActive(sessionId, active) return active.cancelPromise } private async cancelActive(sessionId: string, active: ActivePrompt): Promise { - const timeoutMs = this.options.cancelTimeoutMs ?? 10_000 + const timeoutMs = Math.min(this.options.cancelTimeoutMs ?? 10_000, MAX_TIMER_DELAY_MS) let timer: ReturnType | undefined try { await Promise.race([ @@ -195,21 +202,21 @@ export class AcpSessionRuntime { } } - setMode(modeId: string): Promise { + async setMode(modeId: string): Promise { return this.call( 'session/set_mode', { sessionId: this.sessionId(), modeId }, SetSessionModeResponseSchema ) } - setModel(modelId: string): Promise { + async setModel(modelId: string): Promise { return this.call( 'session/set_model', { sessionId: this.sessionId(), modelId }, SetSessionModelResponseSchema ) } - setConfigOption( + async setConfigOption( configId: SetSessionConfigOptionRequest['configId'], value: SetSessionConfigOptionRequest['value'] ): Promise { @@ -221,6 +228,7 @@ export class AcpSessionRuntime { return this.call('session/set_config_option', request, SetSessionConfigOptionResponseSchema) } + // The process owner must call close on child exit, even if descendants keep stdio open. close(error?: Error): void { this.peer.close(error) this.listeners.clear() @@ -233,16 +241,10 @@ export class AcpSessionRuntime { return this.started.sessionId } - private async call( - method: string, - params: unknown, - schema: z.ZodType, - options?: { timeoutMs?: number } - ): Promise { - const result = await this.peer.request(method, params, options).catch((error) => { - // A timed-out mutation may still be executing; this runtime cannot safely reuse the session. - if (error instanceof AcpRequestTimeoutError) { - this.peer.close(error) + private async call(method: string, params: unknown, schema: z.ZodType): Promise { + const result = await this.peer.request(method, params, { timeoutMs: null }).catch((error) => { + if (error instanceof AcpRpcError && error.code === -32000) { + throw new AcpAuthRequiredError(error.message, error.data) } throw error }) @@ -255,7 +257,10 @@ export class AcpSessionRuntime { private handleRequest(method: string, params: unknown, context: AcpRequestContext): unknown { if (method !== 'session/request_permission') { - return this.options.onRequest?.(method, params, context) + if (!this.options.onRequest) { + throw new AcpRpcError(-32601, `Unknown ACP client method: ${method}`) + } + return this.options.onRequest(method, params, context) } const parsed = RequestPermissionRequestSchema.safeParse(params) if (!parsed.success) { @@ -275,14 +280,22 @@ export class AcpSessionRuntime { if (method !== 'session/update') { return } - const parsed = SessionNotificationSchema.safeParse(params) - if (!parsed.success) { - this.options.onDiagnostic?.('Ignored invalid ACP session update') + const envelope = updateEnvelopeSchema.safeParse(params) + if (!envelope.success) { + this.options.onDiagnostic?.('Ignored invalid ACP session update envelope') return } + const parsed = SessionNotificationSchema.safeParse(params) + const event: AcpSessionEvent = parsed.success + ? { kind: 'known', notification: parsed.data } + : { kind: 'unrecognized', sessionId: envelope.data.sessionId, raw: envelope.data } + if (!parsed.success && !this.reportedUpdateAnomaly) { + this.reportedUpdateAnomaly = true + this.options.onDiagnostic?.('Forwarded unrecognized ACP session update') + } for (const listener of this.listeners) { try { - listener(parsed.data) + listener(event) } catch (error) { this.options.onDiagnostic?.(`ACP event listener failed: ${String(error)}`) } diff --git a/src/main/acp/acp-session-setup.ts b/src/main/acp/acp-session-setup.ts index 6c81f0fc78e5..8296315eb269 100644 --- a/src/main/acp/acp-session-setup.ts +++ b/src/main/acp/acp-session-setup.ts @@ -5,17 +5,25 @@ import { LoadSessionResponseSchema, ResumeSessionResponseSchema, AuthenticateResponseSchema, + SessionModelStateSchema, type InitializeResponse, - type NewSessionRequest, - type NewSessionResponse, - type LoadSessionResponse, - type ResumeSessionResponse -} from './generated/protocol.gen' + type NewSessionRequest +} from './generated/acp-protocol.generated' + +const newSessionSchema = NewSessionResponseSchema.extend({ + models: SessionModelStateSchema.optional() +}) +const loadSessionSchema = LoadSessionResponseSchema.extend({ + models: SessionModelStateSchema.optional() +}) +const resumeSessionSchema = ResumeSessionResponseSchema.extend({ + models: SessionModelStateSchema.optional() +}) export type AcpSessionStarted = - | { kind: 'new'; sessionId: string; response: NewSessionResponse } - | { kind: 'load'; sessionId: string; response: LoadSessionResponse } - | { kind: 'resume'; sessionId: string; response: ResumeSessionResponse } + | { kind: 'new'; sessionId: string; response: z.infer } + | { kind: 'load'; sessionId: string; response: z.infer } + | { kind: 'resume'; sessionId: string; response: z.infer } export type AcpSessionStartOptions = NewSessionRequest & { sessionId?: string resumePreference?: 'load' | 'resume' @@ -31,7 +39,7 @@ export async function setupAcpSession( const setup = async (): Promise => { const { sessionId, resumePreference, authMethodId: _auth, ...params } = options if (sessionId === undefined) { - const response = await request('session/new', params, NewSessionResponseSchema) + const response = await request('session/new', params, newSessionSchema) return { kind: 'new', sessionId: response.sessionId, response } } const capabilities = initialized.agentCapabilities @@ -41,18 +49,14 @@ export async function setupAcpSession( return { kind: 'load', sessionId, - response: await request('session/load', { ...params, sessionId }, LoadSessionResponseSchema) + response: await request('session/load', { ...params, sessionId }, loadSessionSchema) } } if (resume) { return { kind: 'resume', sessionId, - response: await request( - 'session/resume', - { ...params, sessionId }, - ResumeSessionResponseSchema - ) + response: await request('session/resume', { ...params, sessionId }, resumeSessionSchema) } } throw new AcpRpcError(-32601, 'ACP agent cannot load or resume this session') @@ -63,13 +67,19 @@ export async function setupAcpSession( if (!(error instanceof AcpAuthRequiredError)) { throw error } - const method = initialized.authMethods?.find((method) => - options.authMethodId ? method.id === options.authMethodId : method.type !== 'terminal' - ) - if (!method || method.type === 'terminal') { - throw error + const required = new AcpAuthRequiredError(error.message, error.data, initialized.authMethods) + const method = initialized.authMethods?.find((method) => method.id === options.authMethodId) + if (!method) { + throw required + } + try { + await request('authenticate', { methodId: method.id }, AuthenticateResponseSchema) + return await setup() + } catch (failure) { + if (failure instanceof AcpAuthRequiredError) { + throw new AcpAuthRequiredError(failure.message, failure.data, initialized.authMethods) + } + throw failure } - await request('authenticate', { methodId: method.id }, AuthenticateResponseSchema) - return setup() } } diff --git a/src/main/acp/generated/acp-protocol.generated.ts b/src/main/acp/generated/acp-protocol.generated.ts new file mode 100644 index 000000000000..4438895c9e31 --- /dev/null +++ b/src/main/acp/generated/acp-protocol.generated.ts @@ -0,0 +1,1390 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +/* +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 Zed Industries, Inc. and contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +import { z } from 'zod' +export const ACP_SCHEMA_RELEASE = 'schema-v1.21.0' +export const ACP_LEGACY_MODEL_SCHEMA_RELEASE = 'v0.11.6' +export const ACP_PROTOCOL_VERSION = 1 +export const ProtocolVersionSchema = z.number().int().min(0).max(65535) +export type ProtocolVersion = z.infer + +export const FileSystemCapabilitiesSchema = z.looseObject({ + readTextFile: z.boolean().optional(), + writeTextFile: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type FileSystemCapabilities = z.infer + +export const CompactionCapabilitiesSchema = z.looseObject({}) +export type CompactionCapabilities = z.infer + +export const BooleanConfigOptionCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type BooleanConfigOptionCapabilities = z.infer + +export const SessionConfigOptionsCapabilitiesSchema = z.looseObject({ + boolean: z.union([BooleanConfigOptionCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionConfigOptionsCapabilities = z.infer< + typeof SessionConfigOptionsCapabilitiesSchema +> + +export const ClientSessionCapabilitiesSchema = z.looseObject({ + compaction: z.union([CompactionCapabilitiesSchema, z.null()]).optional(), + configOptions: z.union([SessionConfigOptionsCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ClientSessionCapabilities = z.infer + +export const PlanCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanCapabilities = z.infer + +export const AuthCapabilitiesSchema = z.looseObject({ + terminal: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthCapabilities = z.infer + +export const ElicitationFormCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ElicitationFormCapabilities = z.infer + +export const ElicitationUrlCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ElicitationUrlCapabilities = z.infer + +export const ElicitationCapabilitiesSchema = z.looseObject({ + form: z.union([ElicitationFormCapabilitiesSchema, z.null()]).optional(), + url: z.union([ElicitationUrlCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ElicitationCapabilities = z.infer + +export const NesJumpCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesJumpCapabilities = z.infer + +export const NesRenameCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesRenameCapabilities = z.infer + +export const NesSearchAndReplaceCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesSearchAndReplaceCapabilities = z.infer + +export const ClientNesCapabilitiesSchema = z.looseObject({ + jump: z.union([NesJumpCapabilitiesSchema, z.null()]).optional(), + rename: z.union([NesRenameCapabilitiesSchema, z.null()]).optional(), + searchAndReplace: z.union([NesSearchAndReplaceCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ClientNesCapabilities = z.infer + +export const PositionEncodingKindSchema = z.union([ + z.literal('utf-16'), + z.literal('utf-32'), + z.literal('utf-8') +]) +export type PositionEncodingKind = z.infer + +export const ClientCapabilitiesSchema = z.looseObject({ + fs: FileSystemCapabilitiesSchema.optional(), + terminal: z.boolean().optional(), + session: z.union([ClientSessionCapabilitiesSchema, z.null()]).optional(), + plan: z.union([PlanCapabilitiesSchema, z.null()]).optional(), + auth: AuthCapabilitiesSchema.optional(), + elicitation: z.union([ElicitationCapabilitiesSchema, z.null()]).optional(), + nes: z.union([ClientNesCapabilitiesSchema, z.null()]).optional(), + positionEncodings: z.array(PositionEncodingKindSchema).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ClientCapabilities = z.infer + +export const ImplementationSchema = z.looseObject({ + name: z.string(), + title: z.union([z.string(), z.null()]).optional(), + version: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Implementation = z.infer + +export const InitializeRequestSchema = z.looseObject({ + protocolVersion: ProtocolVersionSchema, + clientCapabilities: ClientCapabilitiesSchema.optional(), + clientInfo: z.union([ImplementationSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type InitializeRequest = z.infer + +export const PromptCapabilitiesSchema = z.looseObject({ + image: z.boolean().optional(), + audio: z.boolean().optional(), + embeddedContext: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PromptCapabilities = z.infer + +export const McpCapabilitiesSchema = z.looseObject({ + http: z.boolean().optional(), + sse: z.boolean().optional(), + acp: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpCapabilities = z.infer + +export const SessionListCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionListCapabilities = z.infer + +export const SessionDeleteCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionDeleteCapabilities = z.infer + +export const SessionAdditionalDirectoriesCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionAdditionalDirectoriesCapabilities = z.infer< + typeof SessionAdditionalDirectoriesCapabilitiesSchema +> + +export const SessionForkCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionForkCapabilities = z.infer + +export const SessionResumeCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionResumeCapabilities = z.infer + +export const SessionCloseCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionCloseCapabilities = z.infer + +export const SessionCapabilitiesSchema = z.looseObject({ + list: z.union([SessionListCapabilitiesSchema, z.null()]).optional(), + delete: z.union([SessionDeleteCapabilitiesSchema, z.null()]).optional(), + additionalDirectories: z + .union([SessionAdditionalDirectoriesCapabilitiesSchema, z.null()]) + .optional(), + fork: z.union([SessionForkCapabilitiesSchema, z.null()]).optional(), + resume: z.union([SessionResumeCapabilitiesSchema, z.null()]).optional(), + close: z.union([SessionCloseCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionCapabilities = z.infer + +export const LogoutCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type LogoutCapabilities = z.infer + +export const AgentAuthCapabilitiesSchema = z.looseObject({ + logout: z.union([LogoutCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AgentAuthCapabilities = z.infer + +export const ProvidersCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ProvidersCapabilities = z.infer + +export const NesDocumentDidOpenCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidOpenCapabilities = z.infer + +export const TextDocumentSyncKindSchema = z.union([z.literal('full'), z.literal('incremental')]) +export type TextDocumentSyncKind = z.infer + +export const NesDocumentDidChangeCapabilitiesSchema = z.looseObject({ + syncKind: TextDocumentSyncKindSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidChangeCapabilities = z.infer< + typeof NesDocumentDidChangeCapabilitiesSchema +> + +export const NesDocumentDidCloseCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidCloseCapabilities = z.infer + +export const NesDocumentDidSaveCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidSaveCapabilities = z.infer + +export const NesDocumentDidFocusCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidFocusCapabilities = z.infer + +export const NesDocumentEventCapabilitiesSchema = z.looseObject({ + didOpen: z.union([NesDocumentDidOpenCapabilitiesSchema, z.null()]).optional(), + didChange: z.union([NesDocumentDidChangeCapabilitiesSchema, z.null()]).optional(), + didClose: z.union([NesDocumentDidCloseCapabilitiesSchema, z.null()]).optional(), + didSave: z.union([NesDocumentDidSaveCapabilitiesSchema, z.null()]).optional(), + didFocus: z.union([NesDocumentDidFocusCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentEventCapabilities = z.infer + +export const NesEventCapabilitiesSchema = z.looseObject({ + document: z.union([NesDocumentEventCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesEventCapabilities = z.infer + +export const NesRecentFilesCapabilitiesSchema = z.looseObject({ + maxCount: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesRecentFilesCapabilities = z.infer + +export const NesRelatedSnippetsCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesRelatedSnippetsCapabilities = z.infer + +export const NesEditHistoryCapabilitiesSchema = z.looseObject({ + maxCount: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesEditHistoryCapabilities = z.infer + +export const NesUserActionsCapabilitiesSchema = z.looseObject({ + maxCount: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesUserActionsCapabilities = z.infer + +export const NesOpenFilesCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesOpenFilesCapabilities = z.infer + +export const NesDiagnosticsCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDiagnosticsCapabilities = z.infer + +export const NesContextCapabilitiesSchema = z.looseObject({ + recentFiles: z.union([NesRecentFilesCapabilitiesSchema, z.null()]).optional(), + relatedSnippets: z.union([NesRelatedSnippetsCapabilitiesSchema, z.null()]).optional(), + editHistory: z.union([NesEditHistoryCapabilitiesSchema, z.null()]).optional(), + userActions: z.union([NesUserActionsCapabilitiesSchema, z.null()]).optional(), + openFiles: z.union([NesOpenFilesCapabilitiesSchema, z.null()]).optional(), + diagnostics: z.union([NesDiagnosticsCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesContextCapabilities = z.infer + +export const NesCapabilitiesSchema = z.looseObject({ + events: z.union([NesEventCapabilitiesSchema, z.null()]).optional(), + context: z.union([NesContextCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesCapabilities = z.infer + +export const AgentCapabilitiesSchema = z.looseObject({ + loadSession: z.boolean().optional(), + promptCapabilities: PromptCapabilitiesSchema.optional(), + mcpCapabilities: McpCapabilitiesSchema.optional(), + sessionCapabilities: SessionCapabilitiesSchema.optional(), + auth: AgentAuthCapabilitiesSchema.optional(), + providers: z.union([ProvidersCapabilitiesSchema, z.null()]).optional(), + nes: z.union([NesCapabilitiesSchema, z.null()]).optional(), + positionEncoding: z.union([PositionEncodingKindSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AgentCapabilities = z.infer + +export const AuthMethodIdSchema = z.string() +export type AuthMethodId = z.infer + +export const AuthMethodTerminalSchema = z.looseObject({ + id: AuthMethodIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + args: z.array(z.string()).optional(), + env: z.looseObject({}).catchall(z.string()).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthMethodTerminal = z.infer + +export const AuthMethodAgentSchema = z.looseObject({ + id: AuthMethodIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthMethodAgent = z.infer + +export const AuthMethodSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('terminal') }), AuthMethodTerminalSchema), + AuthMethodAgentSchema +]) +export type AuthMethod = z.infer + +export const InitializeResponseSchema = z.looseObject({ + protocolVersion: ProtocolVersionSchema, + agentCapabilities: AgentCapabilitiesSchema.optional(), + authMethods: z.array(AuthMethodSchema).optional(), + agentInfo: z.union([ImplementationSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type InitializeResponse = z.infer + +export const AuthenticateRequestSchema = z.looseObject({ + methodId: AuthMethodIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthenticateRequest = z.infer + +export const AuthenticateResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthenticateResponse = z.infer + +export const HttpHeaderSchema = z.looseObject({ + name: z.string(), + value: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type HttpHeader = z.infer + +export const McpServerHttpSchema = z.looseObject({ + name: z.string(), + url: z.string(), + headers: z.array(HttpHeaderSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerHttp = z.infer + +export const McpServerSseSchema = z.looseObject({ + name: z.string(), + url: z.string(), + headers: z.array(HttpHeaderSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerSse = z.infer + +export const McpServerAcpIdSchema = z.string() +export type McpServerAcpId = z.infer + +export const McpServerAcpSchema = z.looseObject({ + name: z.string(), + serverId: McpServerAcpIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerAcp = z.infer + +export const EnvVariableSchema = z.looseObject({ + name: z.string(), + value: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type EnvVariable = z.infer + +export const McpServerStdioSchema = z.looseObject({ + name: z.string(), + command: z.string(), + args: z.array(z.string()), + env: z.array(EnvVariableSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerStdio = z.infer + +export const McpServerSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('http') }), McpServerHttpSchema), + z.intersection(z.looseObject({ type: z.literal('sse') }), McpServerSseSchema), + z.intersection(z.looseObject({ type: z.literal('acp') }), McpServerAcpSchema), + McpServerStdioSchema +]) +export type McpServer = z.infer + +export const NewSessionRequestSchema = z.looseObject({ + cwd: z.string(), + additionalDirectories: z.array(z.string()).optional(), + mcpServers: z.array(McpServerSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NewSessionRequest = z.infer + +export const SessionIdSchema = z.string() +export type SessionId = z.infer + +export const SessionModeIdSchema = z.string() +export type SessionModeId = z.infer + +export const SessionModeSchema = z.looseObject({ + id: SessionModeIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionMode = z.infer + +export const SessionModeStateSchema = z.looseObject({ + currentModeId: SessionModeIdSchema, + availableModes: z.array(SessionModeSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionModeState = z.infer + +export const SessionConfigIdSchema = z.string() +export type SessionConfigId = z.infer + +export const SessionConfigOptionCategorySchema = z.union([ + z.literal('mode'), + z.literal('model'), + z.literal('model_config'), + z.literal('thought_level'), + z.string() +]) +export type SessionConfigOptionCategory = z.infer + +export const SessionConfigValueIdSchema = z.string() +export type SessionConfigValueId = z.infer + +export const SessionConfigSelectOptionSchema = z.looseObject({ + value: SessionConfigValueIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionConfigSelectOption = z.infer + +export const SessionConfigGroupIdSchema = z.string() +export type SessionConfigGroupId = z.infer + +export const SessionConfigSelectGroupSchema = z.looseObject({ + group: SessionConfigGroupIdSchema, + name: z.string(), + options: z.array(SessionConfigSelectOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionConfigSelectGroup = z.infer + +export const SessionConfigSelectOptionsSchema = z.union([ + z.array(SessionConfigSelectOptionSchema), + z.array(SessionConfigSelectGroupSchema) +]) +export type SessionConfigSelectOptions = z.infer + +export const SessionConfigSelectSchema = z.looseObject({ + currentValue: SessionConfigValueIdSchema, + options: SessionConfigSelectOptionsSchema +}) +export type SessionConfigSelect = z.infer + +export const SessionConfigBooleanSchema = z.looseObject({ currentValue: z.boolean() }) +export type SessionConfigBoolean = z.infer + +export const SessionConfigOptionSchema = z.intersection( + z.looseObject({ + id: SessionConfigIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + category: z.union([SessionConfigOptionCategorySchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() + }), + z.union([ + z.intersection(z.looseObject({ type: z.literal('select') }), SessionConfigSelectSchema), + z.intersection(z.looseObject({ type: z.literal('boolean') }), SessionConfigBooleanSchema) + ]) +) +export type SessionConfigOption = z.infer + +export const NewSessionResponseSchema = z.looseObject({ + sessionId: SessionIdSchema, + modes: z.union([SessionModeStateSchema, z.null()]).optional(), + configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NewSessionResponse = z.infer + +export const LoadSessionRequestSchema = z.looseObject({ + mcpServers: z.array(McpServerSchema), + cwd: z.string(), + additionalDirectories: z.array(z.string()).optional(), + sessionId: SessionIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type LoadSessionRequest = z.infer + +export const LoadSessionResponseSchema = z.looseObject({ + modes: z.union([SessionModeStateSchema, z.null()]).optional(), + configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type LoadSessionResponse = z.infer + +export const ResumeSessionRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + cwd: z.string(), + additionalDirectories: z.array(z.string()).optional(), + mcpServers: z.array(McpServerSchema).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ResumeSessionRequest = z.infer + +export const ResumeSessionResponseSchema = z.looseObject({ + modes: z.union([SessionModeStateSchema, z.null()]).optional(), + configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ResumeSessionResponse = z.infer + +export const RoleSchema = z.union([z.literal('assistant'), z.literal('user')]) +export type Role = z.infer + +export const AnnotationsSchema = z.looseObject({ + audience: z.union([z.array(RoleSchema), z.null()]).optional(), + lastModified: z.union([z.string(), z.null()]).optional(), + priority: z.union([z.number(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Annotations = z.infer + +export const TextContentSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + text: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TextContent = z.infer + +export const ImageContentSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + data: z.string(), + mimeType: z.string(), + uri: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ImageContent = z.infer + +export const AudioContentSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + data: z.string(), + mimeType: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AudioContent = z.infer + +export const ResourceLinkSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + description: z.union([z.string(), z.null()]).optional(), + mimeType: z.union([z.string(), z.null()]).optional(), + name: z.string(), + size: z.union([z.number().int(), z.null()]).optional(), + title: z.union([z.string(), z.null()]).optional(), + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ResourceLink = z.infer + +export const TextResourceContentsSchema = z.looseObject({ + mimeType: z.union([z.string(), z.null()]).optional(), + text: z.string(), + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TextResourceContents = z.infer + +export const BlobResourceContentsSchema = z.looseObject({ + blob: z.string(), + mimeType: z.union([z.string(), z.null()]).optional(), + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type BlobResourceContents = z.infer + +export const EmbeddedResourceResourceSchema = z.union([ + TextResourceContentsSchema, + BlobResourceContentsSchema +]) +export type EmbeddedResourceResource = z.infer + +export const EmbeddedResourceSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + resource: EmbeddedResourceResourceSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type EmbeddedResource = z.infer + +export const ContentBlockSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('text') }), TextContentSchema), + z.intersection(z.looseObject({ type: z.literal('image') }), ImageContentSchema), + z.intersection(z.looseObject({ type: z.literal('audio') }), AudioContentSchema), + z.intersection(z.looseObject({ type: z.literal('resource_link') }), ResourceLinkSchema), + z.intersection(z.looseObject({ type: z.literal('resource') }), EmbeddedResourceSchema) +]) +export type ContentBlock = z.infer + +export const PromptRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + prompt: z.array(ContentBlockSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PromptRequest = z.infer + +export const StopReasonSchema = z.union([ + z.literal('end_turn'), + z.literal('max_tokens'), + z.literal('max_turn_requests'), + z.literal('refusal'), + z.literal('cancelled') +]) +export type StopReason = z.infer + +export const UsageSchema = z.looseObject({ + totalTokens: z.number().int().min(0), + inputTokens: z.number().int().min(0), + outputTokens: z.number().int().min(0), + thoughtTokens: z.union([z.number().int().min(0), z.null()]).optional(), + cachedReadTokens: z.union([z.number().int().min(0), z.null()]).optional(), + cachedWriteTokens: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Usage = z.infer + +export const PromptResponseSchema = z.looseObject({ + stopReason: StopReasonSchema, + usage: z.union([UsageSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PromptResponse = z.infer + +export const CancelNotificationSchema = z.looseObject({ + sessionId: SessionIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CancelNotification = z.infer + +export const MessageIdSchema = z.string() +export type MessageId = z.infer + +export const ContentChunkSchema = z.looseObject({ + content: ContentBlockSchema, + messageId: z.union([MessageIdSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ContentChunk = z.infer + +export const ToolCallIdSchema = z.string() +export type ToolCallId = z.infer + +export const ToolKindSchema = z.union([ + z.literal('read'), + z.literal('edit'), + z.literal('delete'), + z.literal('move'), + z.literal('search'), + z.literal('execute'), + z.literal('think'), + z.literal('fetch'), + z.literal('switch_mode'), + z.literal('other') +]) +export type ToolKind = z.infer + +export const ToolCallStatusSchema = z.union([ + z.literal('pending'), + z.literal('in_progress'), + z.literal('completed'), + z.literal('failed') +]) +export type ToolCallStatus = z.infer + +export const ContentSchema = z.looseObject({ + content: ContentBlockSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Content = z.infer + +export const DiffSchema = z.looseObject({ + path: z.string(), + oldText: z.union([z.string(), z.null()]).optional(), + newText: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Diff = z.infer + +export const TerminalIdSchema = z.string() +export type TerminalId = z.infer + +export const TerminalSchema = z.looseObject({ + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Terminal = z.infer + +export const ToolCallContentSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('content') }), ContentSchema), + z.intersection(z.looseObject({ type: z.literal('diff') }), DiffSchema), + z.intersection(z.looseObject({ type: z.literal('terminal') }), TerminalSchema) +]) +export type ToolCallContent = z.infer + +export const ToolCallLocationSchema = z.looseObject({ + path: z.string(), + line: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ToolCallLocation = z.infer + +export const ToolCallSchema = z.looseObject({ + toolCallId: ToolCallIdSchema, + title: z.string(), + name: z.union([z.string(), z.null()]).optional(), + kind: ToolKindSchema.optional(), + status: ToolCallStatusSchema.optional(), + content: z.array(ToolCallContentSchema).optional(), + locations: z.array(ToolCallLocationSchema).optional(), + rawInput: z.unknown().optional(), + rawOutput: z.unknown().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ToolCall = z.infer + +export const ToolCallUpdateSchema = z.looseObject({ + toolCallId: ToolCallIdSchema, + kind: z.union([ToolKindSchema, z.null()]).optional(), + status: z.union([ToolCallStatusSchema, z.null()]).optional(), + title: z.union([z.string(), z.null()]).optional(), + name: z.union([z.string(), z.null()]).optional(), + content: z.union([z.array(ToolCallContentSchema), z.null()]).optional(), + locations: z.union([z.array(ToolCallLocationSchema), z.null()]).optional(), + rawInput: z.unknown().optional(), + rawOutput: z.unknown().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ToolCallUpdate = z.infer + +export const PlanEntryPrioritySchema = z.union([ + z.literal('high'), + z.literal('medium'), + z.literal('low') +]) +export type PlanEntryPriority = z.infer + +export const PlanEntryStatusSchema = z.union([ + z.literal('pending'), + z.literal('in_progress'), + z.literal('completed') +]) +export type PlanEntryStatus = z.infer + +export const PlanEntrySchema = z.looseObject({ + content: z.string(), + priority: PlanEntryPrioritySchema, + status: PlanEntryStatusSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanEntry = z.infer + +export const PlanSchema = z.looseObject({ + entries: z.array(PlanEntrySchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Plan = z.infer + +export const PlanIdSchema = z.string() +export type PlanId = z.infer + +export const PlanItemsSchema = z.looseObject({ + planId: PlanIdSchema, + entries: z.array(PlanEntrySchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanItems = z.infer + +export const PlanFileSchema = z.looseObject({ + planId: PlanIdSchema, + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanFile = z.infer + +export const PlanMarkdownSchema = z.looseObject({ + planId: PlanIdSchema, + content: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanMarkdown = z.infer + +export const PlanUpdateContentSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('items') }), PlanItemsSchema), + z.intersection(z.looseObject({ type: z.literal('file') }), PlanFileSchema), + z.intersection(z.looseObject({ type: z.literal('markdown') }), PlanMarkdownSchema) +]) +export type PlanUpdateContent = z.infer + +export const PlanUpdateSchema = z.looseObject({ + plan: PlanUpdateContentSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanUpdate = z.infer + +export const PlanRemovedSchema = z.looseObject({ + planId: PlanIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanRemoved = z.infer + +export const UnstructuredCommandInputSchema = z.looseObject({ + hint: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type UnstructuredCommandInput = z.infer + +export const AvailableCommandInputSchema = z.union([UnstructuredCommandInputSchema]) +export type AvailableCommandInput = z.infer + +export const AvailableCommandSchema = z.looseObject({ + name: z.string(), + description: z.string(), + input: z.union([AvailableCommandInputSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AvailableCommand = z.infer + +export const AvailableCommandsUpdateSchema = z.looseObject({ + availableCommands: z.array(AvailableCommandSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AvailableCommandsUpdate = z.infer + +export const CurrentModeUpdateSchema = z.looseObject({ + currentModeId: SessionModeIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CurrentModeUpdate = z.infer + +export const ConfigOptionUpdateSchema = z.looseObject({ + configOptions: z.array(SessionConfigOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ConfigOptionUpdate = z.infer + +export const SessionInfoUpdateSchema = z.looseObject({ + title: z.union([z.string(), z.null()]).optional(), + updatedAt: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionInfoUpdate = z.infer + +export const CostSchema = z.looseObject({ + amount: z.number(), + currency: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Cost = z.infer + +export const UsageUpdateSchema = z.looseObject({ + used: z.number().int().min(0), + size: z.number().int().min(0), + cost: z.union([CostSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type UsageUpdate = z.infer + +export const CompactionIdSchema = z.string() +export type CompactionId = z.infer + +export const CompactionStatusSchema = z.union([ + z.literal('in_progress'), + z.literal('completed'), + z.literal('failed'), + z.literal('cancelled'), + z.string() +]) +export type CompactionStatus = z.infer + +export const CompactionUpdateSchema = z.looseObject({ + compactionId: CompactionIdSchema, + status: CompactionStatusSchema, + summary: z.union([z.array(ContentBlockSchema), z.null()]).optional(), + error: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CompactionUpdate = z.infer + +export const CompactionSummaryChunkSchema = z.looseObject({ + compactionId: CompactionIdSchema, + content: ContentBlockSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CompactionSummaryChunk = z.infer + +export const SessionUpdateSchema = z.union([ + z.intersection( + z.looseObject({ sessionUpdate: z.literal('user_message_chunk') }), + ContentChunkSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('agent_message_chunk') }), + ContentChunkSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('agent_thought_chunk') }), + ContentChunkSchema + ), + z.intersection(z.looseObject({ sessionUpdate: z.literal('tool_call') }), ToolCallSchema), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('tool_call_update') }), + ToolCallUpdateSchema + ), + z.intersection(z.looseObject({ sessionUpdate: z.literal('plan') }), PlanSchema), + z.intersection(z.looseObject({ sessionUpdate: z.literal('plan_update') }), PlanUpdateSchema), + z.intersection(z.looseObject({ sessionUpdate: z.literal('plan_removed') }), PlanRemovedSchema), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('available_commands_update') }), + AvailableCommandsUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('current_mode_update') }), + CurrentModeUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('config_option_update') }), + ConfigOptionUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('session_info_update') }), + SessionInfoUpdateSchema + ), + z.intersection(z.looseObject({ sessionUpdate: z.literal('usage_update') }), UsageUpdateSchema), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('compaction_update') }), + CompactionUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('compaction_summary_chunk') }), + CompactionSummaryChunkSchema + ) +]) +export type SessionUpdate = z.infer + +export const SessionNotificationSchema = z.looseObject({ + sessionId: SessionIdSchema, + update: SessionUpdateSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionNotification = z.infer + +export const PermissionOptionIdSchema = z.string() +export type PermissionOptionId = z.infer + +export const PermissionOptionKindSchema = z.union([ + z.literal('allow_once'), + z.literal('allow_always'), + z.literal('reject_once'), + z.literal('reject_always') +]) +export type PermissionOptionKind = z.infer + +export const PermissionOptionSchema = z.looseObject({ + optionId: PermissionOptionIdSchema, + name: z.string(), + kind: PermissionOptionKindSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PermissionOption = z.infer + +export const RequestPermissionRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + toolCall: ToolCallUpdateSchema, + options: z.array(PermissionOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type RequestPermissionRequest = z.infer + +export const SelectedPermissionOutcomeSchema = z.looseObject({ + optionId: PermissionOptionIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SelectedPermissionOutcome = z.infer + +export const RequestPermissionOutcomeSchema = z.union([ + z.looseObject({ outcome: z.literal('cancelled') }), + z.intersection(z.looseObject({ outcome: z.literal('selected') }), SelectedPermissionOutcomeSchema) +]) +export type RequestPermissionOutcome = z.infer + +export const RequestPermissionResponseSchema = z.looseObject({ + outcome: RequestPermissionOutcomeSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type RequestPermissionResponse = z.infer + +export const SetSessionModeRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + modeId: SessionModeIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionModeRequest = z.infer + +export const SetSessionModeResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionModeResponse = z.infer + +export const ModelIdSchema = z.string() +export type ModelId = z.infer + +export const SetSessionModelRequestSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional(), + modelId: ModelIdSchema, + sessionId: SessionIdSchema +}) +export type SetSessionModelRequest = z.infer + +export const SetSessionModelResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionModelResponse = z.infer + +export const ModelInfoSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional(), + description: z.union([z.string(), z.null()]).optional(), + modelId: ModelIdSchema, + name: z.string() +}) +export type ModelInfo = z.infer + +export const SessionModelStateSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional(), + availableModels: z.array(ModelInfoSchema), + currentModelId: ModelIdSchema +}) +export type SessionModelState = z.infer + +export const SetSessionConfigOptionRequestSchema = z.intersection( + z.looseObject({ + sessionId: SessionIdSchema, + configId: SessionConfigIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() + }), + z.union([ + z.looseObject({ value: z.boolean(), type: z.literal('boolean') }), + z.looseObject({ value: SessionConfigValueIdSchema }) + ]) +) +export type SetSessionConfigOptionRequest = z.infer + +export const SetSessionConfigOptionResponseSchema = z.looseObject({ + configOptions: z.array(SessionConfigOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionConfigOptionResponse = z.infer + +export const ReadTextFileRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + path: z.string(), + line: z.union([z.number().int().min(0), z.null()]).optional(), + limit: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReadTextFileRequest = z.infer + +export const ReadTextFileResponseSchema = z.looseObject({ + content: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReadTextFileResponse = z.infer + +export const WriteTextFileRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + path: z.string(), + content: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WriteTextFileRequest = z.infer + +export const WriteTextFileResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WriteTextFileResponse = z.infer + +export const CreateTerminalRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + command: z.string(), + args: z.array(z.string()).optional(), + env: z.array(EnvVariableSchema).optional(), + cwd: z.union([z.string(), z.null()]).optional(), + outputByteLimit: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CreateTerminalRequest = z.infer + +export const CreateTerminalResponseSchema = z.looseObject({ + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CreateTerminalResponse = z.infer + +export const TerminalOutputRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TerminalOutputRequest = z.infer + +export const TerminalExitStatusSchema = z.looseObject({ + exitCode: z.union([z.number().int().min(0), z.null()]).optional(), + signal: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TerminalExitStatus = z.infer + +export const TerminalOutputResponseSchema = z.looseObject({ + output: z.string(), + truncated: z.boolean(), + exitStatus: z.union([TerminalExitStatusSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TerminalOutputResponse = z.infer + +export const ReleaseTerminalRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReleaseTerminalRequest = z.infer + +export const ReleaseTerminalResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReleaseTerminalResponse = z.infer + +export const WaitForTerminalExitRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WaitForTerminalExitRequest = z.infer + +export const WaitForTerminalExitResponseSchema = z.looseObject({ + exitCode: z.union([z.number().int().min(0), z.null()]).optional(), + signal: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WaitForTerminalExitResponse = z.infer + +export const KillTerminalRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type KillTerminalRequest = z.infer + +export const KillTerminalResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type KillTerminalResponse = z.infer diff --git a/src/main/acp/generated/client-capabilities.gen.ts b/src/main/acp/generated/client-capabilities.gen.ts deleted file mode 100644 index 723ef2fddb15..000000000000 --- a/src/main/acp/generated/client-capabilities.gen.ts +++ /dev/null @@ -1,113 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { - FileSystemCapabilitiesSchema, - ClientSessionCapabilitiesSchema, - PlanCapabilitiesSchema, - AuthCapabilitiesSchema, - ElicitationCapabilitiesSchema, - ClientNesCapabilitiesSchema, - PositionEncodingKindSchema, - ProtocolVersionSchema -} from './protocol-version.gen' -export const ClientCapabilitiesSchema = z.looseObject({ - fs: FileSystemCapabilitiesSchema.optional(), - terminal: z.boolean().optional(), - session: z.union([ClientSessionCapabilitiesSchema, z.null()]).optional(), - plan: z.union([PlanCapabilitiesSchema, z.null()]).optional(), - auth: AuthCapabilitiesSchema.optional(), - elicitation: z.union([ElicitationCapabilitiesSchema, z.null()]).optional(), - nes: z.union([ClientNesCapabilitiesSchema, z.null()]).optional(), - positionEncodings: z.array(PositionEncodingKindSchema).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ClientCapabilities = z.infer - -export const ImplementationSchema = z.looseObject({ - name: z.string(), - title: z.union([z.string(), z.null()]).optional(), - version: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Implementation = z.infer - -export const InitializeRequestSchema = z.looseObject({ - protocolVersion: ProtocolVersionSchema, - clientCapabilities: ClientCapabilitiesSchema.optional(), - clientInfo: z.union([ImplementationSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type InitializeRequest = z.infer - -export const PromptCapabilitiesSchema = z.looseObject({ - image: z.boolean().optional(), - audio: z.boolean().optional(), - embeddedContext: z.boolean().optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PromptCapabilities = z.infer - -export const McpCapabilitiesSchema = z.looseObject({ - http: z.boolean().optional(), - sse: z.boolean().optional(), - acp: z.boolean().optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type McpCapabilities = z.infer - -export const SessionListCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionListCapabilities = z.infer - -export const SessionDeleteCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionDeleteCapabilities = z.infer - -export const SessionAdditionalDirectoriesCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionAdditionalDirectoriesCapabilities = z.infer< - typeof SessionAdditionalDirectoriesCapabilitiesSchema -> - -export const SessionForkCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionForkCapabilities = z.infer - -export const SessionResumeCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionResumeCapabilities = z.infer - -export const SessionCloseCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionCloseCapabilities = z.infer - -export const SessionCapabilitiesSchema = z.looseObject({ - list: z.union([SessionListCapabilitiesSchema, z.null()]).optional(), - delete: z.union([SessionDeleteCapabilitiesSchema, z.null()]).optional(), - additionalDirectories: z - .union([SessionAdditionalDirectoriesCapabilitiesSchema, z.null()]) - .optional(), - fork: z.union([SessionForkCapabilitiesSchema, z.null()]).optional(), - resume: z.union([SessionResumeCapabilitiesSchema, z.null()]).optional(), - close: z.union([SessionCloseCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionCapabilities = z.infer - -export const LogoutCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type LogoutCapabilities = z.infer - -export const AgentAuthCapabilitiesSchema = z.looseObject({ - logout: z.union([LogoutCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AgentAuthCapabilities = z.infer diff --git a/src/main/acp/generated/diff.gen.ts b/src/main/acp/generated/diff.gen.ts deleted file mode 100644 index 21ec2dbe11ba..000000000000 --- a/src/main/acp/generated/diff.gen.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { - ContentSchema, - ToolCallIdSchema, - ToolKindSchema, - ToolCallStatusSchema -} from './text-resource-contents.gen' -export const DiffSchema = z.looseObject({ - path: z.string(), - oldText: z.union([z.string(), z.null()]).optional(), - newText: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Diff = z.infer - -export const TerminalIdSchema = z.string() -export type TerminalId = z.infer - -export const TerminalSchema = z.looseObject({ - terminalId: TerminalIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Terminal = z.infer - -export const ToolCallContentSchema = z.union([ - z.intersection(z.looseObject({ type: z.literal('content') }), ContentSchema), - z.intersection(z.looseObject({ type: z.literal('diff') }), DiffSchema), - z.intersection(z.looseObject({ type: z.literal('terminal') }), TerminalSchema) -]) -export type ToolCallContent = z.infer - -export const ToolCallLocationSchema = z.looseObject({ - path: z.string(), - line: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ToolCallLocation = z.infer - -export const ToolCallSchema = z.looseObject({ - toolCallId: ToolCallIdSchema, - title: z.string(), - name: z.union([z.string(), z.null()]).optional(), - kind: ToolKindSchema.optional(), - status: ToolCallStatusSchema.optional(), - content: z.array(ToolCallContentSchema).optional(), - locations: z.array(ToolCallLocationSchema).optional(), - rawInput: z.unknown().optional(), - rawOutput: z.unknown().optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ToolCall = z.infer - -export const ToolCallUpdateSchema = z.looseObject({ - toolCallId: ToolCallIdSchema, - kind: z.union([ToolKindSchema, z.null()]).optional(), - status: z.union([ToolCallStatusSchema, z.null()]).optional(), - title: z.union([z.string(), z.null()]).optional(), - name: z.union([z.string(), z.null()]).optional(), - content: z.union([z.array(ToolCallContentSchema), z.null()]).optional(), - locations: z.union([z.array(ToolCallLocationSchema), z.null()]).optional(), - rawInput: z.unknown().optional(), - rawOutput: z.unknown().optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ToolCallUpdate = z.infer - -export const PlanEntryPrioritySchema = z.union([ - z.literal('high'), - z.literal('medium'), - z.literal('low') -]) -export type PlanEntryPriority = z.infer - -export const PlanEntryStatusSchema = z.union([ - z.literal('pending'), - z.literal('in_progress'), - z.literal('completed') -]) -export type PlanEntryStatus = z.infer - -export const PlanEntrySchema = z.looseObject({ - content: z.string(), - priority: PlanEntryPrioritySchema, - status: PlanEntryStatusSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanEntry = z.infer - -export const PlanSchema = z.looseObject({ - entries: z.array(PlanEntrySchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Plan = z.infer - -export const PlanIdSchema = z.string() -export type PlanId = z.infer - -export const PlanItemsSchema = z.looseObject({ - planId: PlanIdSchema, - entries: z.array(PlanEntrySchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanItems = z.infer - -export const PlanFileSchema = z.looseObject({ - planId: PlanIdSchema, - uri: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanFile = z.infer - -export const PlanMarkdownSchema = z.looseObject({ - planId: PlanIdSchema, - content: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanMarkdown = z.infer - -export const PlanUpdateContentSchema = z.union([ - z.intersection(z.looseObject({ type: z.literal('items') }), PlanItemsSchema), - z.intersection(z.looseObject({ type: z.literal('file') }), PlanFileSchema), - z.intersection(z.looseObject({ type: z.literal('markdown') }), PlanMarkdownSchema) -]) -export type PlanUpdateContent = z.infer diff --git a/src/main/acp/generated/license.gen.ts b/src/main/acp/generated/license.gen.ts deleted file mode 100644 index 51e227cb3ce1..000000000000 --- a/src/main/acp/generated/license.gen.ts +++ /dev/null @@ -1,197 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -/* -Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - Copyright 2025 Zed Industries, Inc. and contributors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ -export const ACP_SCHEMA_RELEASE = 'schema-v1.21.0' -export const ACP_LEGACY_MODEL_SCHEMA_RELEASE = 'v0.11.6' -export const ACP_PROTOCOL_VERSION = 1 diff --git a/src/main/acp/generated/mcp-server-acp.gen.ts b/src/main/acp/generated/mcp-server-acp.gen.ts deleted file mode 100644 index 105054e2a883..000000000000 --- a/src/main/acp/generated/mcp-server-acp.gen.ts +++ /dev/null @@ -1,116 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { - McpServerAcpIdSchema, - McpServerHttpSchema, - McpServerSseSchema -} from './nes-context-capabilities.gen' -export const McpServerAcpSchema = z.looseObject({ - name: z.string(), - serverId: McpServerAcpIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type McpServerAcp = z.infer - -export const EnvVariableSchema = z.looseObject({ - name: z.string(), - value: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type EnvVariable = z.infer - -export const McpServerStdioSchema = z.looseObject({ - name: z.string(), - command: z.string(), - args: z.array(z.string()), - env: z.array(EnvVariableSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type McpServerStdio = z.infer - -export const McpServerSchema = z.union([ - z.intersection(z.looseObject({ type: z.literal('http') }), McpServerHttpSchema), - z.intersection(z.looseObject({ type: z.literal('sse') }), McpServerSseSchema), - z.intersection(z.looseObject({ type: z.literal('acp') }), McpServerAcpSchema), - McpServerStdioSchema -]) -export type McpServer = z.infer - -export const NewSessionRequestSchema = z.looseObject({ - cwd: z.string(), - additionalDirectories: z.array(z.string()).optional(), - mcpServers: z.array(McpServerSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NewSessionRequest = z.infer - -export const SessionIdSchema = z.string() -export type SessionId = z.infer - -export const SessionModeIdSchema = z.string() -export type SessionModeId = z.infer - -export const SessionModeSchema = z.looseObject({ - id: SessionModeIdSchema, - name: z.string(), - description: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionMode = z.infer - -export const SessionModeStateSchema = z.looseObject({ - currentModeId: SessionModeIdSchema, - availableModes: z.array(SessionModeSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionModeState = z.infer - -export const SessionConfigIdSchema = z.string() -export type SessionConfigId = z.infer - -export const SessionConfigOptionCategorySchema = z.union([ - z.literal('mode'), - z.literal('model'), - z.literal('model_config'), - z.literal('thought_level'), - z.string() -]) -export type SessionConfigOptionCategory = z.infer - -export const SessionConfigValueIdSchema = z.string() -export type SessionConfigValueId = z.infer - -export const SessionConfigSelectOptionSchema = z.looseObject({ - value: SessionConfigValueIdSchema, - name: z.string(), - description: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionConfigSelectOption = z.infer - -export const SessionConfigGroupIdSchema = z.string() -export type SessionConfigGroupId = z.infer - -export const SessionConfigSelectGroupSchema = z.looseObject({ - group: SessionConfigGroupIdSchema, - name: z.string(), - options: z.array(SessionConfigSelectOptionSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionConfigSelectGroup = z.infer - -export const SessionConfigSelectOptionsSchema = z.union([ - z.array(SessionConfigSelectOptionSchema), - z.array(SessionConfigSelectGroupSchema) -]) -export type SessionConfigSelectOptions = z.infer - -export const SessionConfigSelectSchema = z.looseObject({ - currentValue: SessionConfigValueIdSchema, - options: SessionConfigSelectOptionsSchema -}) -export type SessionConfigSelect = z.infer - -export const SessionConfigBooleanSchema = z.looseObject({ currentValue: z.boolean() }) -export type SessionConfigBoolean = z.infer diff --git a/src/main/acp/generated/nes-context-capabilities.gen.ts b/src/main/acp/generated/nes-context-capabilities.gen.ts deleted file mode 100644 index 3f193cd78045..000000000000 --- a/src/main/acp/generated/nes-context-capabilities.gen.ts +++ /dev/null @@ -1,124 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { - NesRecentFilesCapabilitiesSchema, - NesRelatedSnippetsCapabilitiesSchema, - NesEditHistoryCapabilitiesSchema, - NesUserActionsCapabilitiesSchema, - NesOpenFilesCapabilitiesSchema, - NesDiagnosticsCapabilitiesSchema, - NesEventCapabilitiesSchema, - ProvidersCapabilitiesSchema -} from './providers-capabilities.gen' -import { - PromptCapabilitiesSchema, - McpCapabilitiesSchema, - SessionCapabilitiesSchema, - AgentAuthCapabilitiesSchema, - ImplementationSchema -} from './client-capabilities.gen' -import { PositionEncodingKindSchema, ProtocolVersionSchema } from './protocol-version.gen' -export const NesContextCapabilitiesSchema = z.looseObject({ - recentFiles: z.union([NesRecentFilesCapabilitiesSchema, z.null()]).optional(), - relatedSnippets: z.union([NesRelatedSnippetsCapabilitiesSchema, z.null()]).optional(), - editHistory: z.union([NesEditHistoryCapabilitiesSchema, z.null()]).optional(), - userActions: z.union([NesUserActionsCapabilitiesSchema, z.null()]).optional(), - openFiles: z.union([NesOpenFilesCapabilitiesSchema, z.null()]).optional(), - diagnostics: z.union([NesDiagnosticsCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesContextCapabilities = z.infer - -export const NesCapabilitiesSchema = z.looseObject({ - events: z.union([NesEventCapabilitiesSchema, z.null()]).optional(), - context: z.union([NesContextCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesCapabilities = z.infer - -export const AgentCapabilitiesSchema = z.looseObject({ - loadSession: z.boolean().optional(), - promptCapabilities: PromptCapabilitiesSchema.optional(), - mcpCapabilities: McpCapabilitiesSchema.optional(), - sessionCapabilities: SessionCapabilitiesSchema.optional(), - auth: AgentAuthCapabilitiesSchema.optional(), - providers: z.union([ProvidersCapabilitiesSchema, z.null()]).optional(), - nes: z.union([NesCapabilitiesSchema, z.null()]).optional(), - positionEncoding: z.union([PositionEncodingKindSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AgentCapabilities = z.infer - -export const AuthMethodIdSchema = z.string() -export type AuthMethodId = z.infer - -export const AuthMethodTerminalSchema = z.looseObject({ - id: AuthMethodIdSchema, - name: z.string(), - description: z.union([z.string(), z.null()]).optional(), - args: z.array(z.string()).optional(), - env: z.looseObject({}).catchall(z.string()).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AuthMethodTerminal = z.infer - -export const AuthMethodAgentSchema = z.looseObject({ - id: AuthMethodIdSchema, - name: z.string(), - description: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AuthMethodAgent = z.infer - -export const AuthMethodSchema = z.union([ - z.intersection(z.looseObject({ type: z.literal('terminal') }), AuthMethodTerminalSchema), - AuthMethodAgentSchema -]) -export type AuthMethod = z.infer - -export const InitializeResponseSchema = z.looseObject({ - protocolVersion: ProtocolVersionSchema, - agentCapabilities: AgentCapabilitiesSchema.optional(), - authMethods: z.array(AuthMethodSchema).optional(), - agentInfo: z.union([ImplementationSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type InitializeResponse = z.infer - -export const AuthenticateRequestSchema = z.looseObject({ - methodId: AuthMethodIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AuthenticateRequest = z.infer - -export const AuthenticateResponseSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AuthenticateResponse = z.infer - -export const HttpHeaderSchema = z.looseObject({ - name: z.string(), - value: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type HttpHeader = z.infer - -export const McpServerHttpSchema = z.looseObject({ - name: z.string(), - url: z.string(), - headers: z.array(HttpHeaderSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type McpServerHttp = z.infer - -export const McpServerSseSchema = z.looseObject({ - name: z.string(), - url: z.string(), - headers: z.array(HttpHeaderSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type McpServerSse = z.infer - -export const McpServerAcpIdSchema = z.string() -export type McpServerAcpId = z.infer diff --git a/src/main/acp/generated/plan-update.gen.ts b/src/main/acp/generated/plan-update.gen.ts deleted file mode 100644 index eda1e7474274..000000000000 --- a/src/main/acp/generated/plan-update.gen.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { PlanUpdateContentSchema, PlanIdSchema } from './diff.gen' -import { SessionModeIdSchema } from './mcp-server-acp.gen' -import { SessionConfigOptionSchema } from './session-config-option.gen' -import { ContentBlockSchema } from './text-resource-contents.gen' -export const PlanUpdateSchema = z.looseObject({ - plan: PlanUpdateContentSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanUpdate = z.infer - -export const PlanRemovedSchema = z.looseObject({ - planId: PlanIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanRemoved = z.infer - -export const UnstructuredCommandInputSchema = z.looseObject({ - hint: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type UnstructuredCommandInput = z.infer - -export const AvailableCommandInputSchema = z.union([UnstructuredCommandInputSchema]) -export type AvailableCommandInput = z.infer - -export const AvailableCommandSchema = z.looseObject({ - name: z.string(), - description: z.string(), - input: z.union([AvailableCommandInputSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AvailableCommand = z.infer - -export const AvailableCommandsUpdateSchema = z.looseObject({ - availableCommands: z.array(AvailableCommandSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AvailableCommandsUpdate = z.infer - -export const CurrentModeUpdateSchema = z.looseObject({ - currentModeId: SessionModeIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type CurrentModeUpdate = z.infer - -export const ConfigOptionUpdateSchema = z.looseObject({ - configOptions: z.array(SessionConfigOptionSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ConfigOptionUpdate = z.infer - -export const SessionInfoUpdateSchema = z.looseObject({ - title: z.union([z.string(), z.null()]).optional(), - updatedAt: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionInfoUpdate = z.infer - -export const CostSchema = z.looseObject({ - amount: z.number(), - currency: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Cost = z.infer - -export const UsageUpdateSchema = z.looseObject({ - used: z.number().int().min(0), - size: z.number().int().min(0), - cost: z.union([CostSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type UsageUpdate = z.infer - -export const CompactionIdSchema = z.string() -export type CompactionId = z.infer - -export const CompactionStatusSchema = z.union([ - z.literal('in_progress'), - z.literal('completed'), - z.literal('failed'), - z.literal('cancelled'), - z.string() -]) -export type CompactionStatus = z.infer - -export const CompactionUpdateSchema = z.looseObject({ - compactionId: CompactionIdSchema, - status: CompactionStatusSchema, - summary: z.union([z.array(ContentBlockSchema), z.null()]).optional(), - error: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type CompactionUpdate = z.infer - -export const CompactionSummaryChunkSchema = z.looseObject({ - compactionId: CompactionIdSchema, - content: ContentBlockSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type CompactionSummaryChunk = z.infer diff --git a/src/main/acp/generated/protocol-version.gen.ts b/src/main/acp/generated/protocol-version.gen.ts deleted file mode 100644 index 865debf5666e..000000000000 --- a/src/main/acp/generated/protocol-version.gen.ts +++ /dev/null @@ -1,93 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -export const ProtocolVersionSchema = z.number().int().min(0).max(65535) -export type ProtocolVersion = z.infer - -export const FileSystemCapabilitiesSchema = z.looseObject({ - readTextFile: z.boolean().optional(), - writeTextFile: z.boolean().optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type FileSystemCapabilities = z.infer - -export const CompactionCapabilitiesSchema = z.looseObject({}) -export type CompactionCapabilities = z.infer - -export const BooleanConfigOptionCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type BooleanConfigOptionCapabilities = z.infer - -export const SessionConfigOptionsCapabilitiesSchema = z.looseObject({ - boolean: z.union([BooleanConfigOptionCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionConfigOptionsCapabilities = z.infer< - typeof SessionConfigOptionsCapabilitiesSchema -> - -export const ClientSessionCapabilitiesSchema = z.looseObject({ - compaction: z.union([CompactionCapabilitiesSchema, z.null()]).optional(), - configOptions: z.union([SessionConfigOptionsCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ClientSessionCapabilities = z.infer - -export const PlanCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanCapabilities = z.infer - -export const AuthCapabilitiesSchema = z.looseObject({ - terminal: z.boolean().optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AuthCapabilities = z.infer - -export const ElicitationFormCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ElicitationFormCapabilities = z.infer - -export const ElicitationUrlCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ElicitationUrlCapabilities = z.infer - -export const ElicitationCapabilitiesSchema = z.looseObject({ - form: z.union([ElicitationFormCapabilitiesSchema, z.null()]).optional(), - url: z.union([ElicitationUrlCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ElicitationCapabilities = z.infer - -export const NesJumpCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesJumpCapabilities = z.infer - -export const NesRenameCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesRenameCapabilities = z.infer - -export const NesSearchAndReplaceCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesSearchAndReplaceCapabilities = z.infer - -export const ClientNesCapabilitiesSchema = z.looseObject({ - jump: z.union([NesJumpCapabilitiesSchema, z.null()]).optional(), - rename: z.union([NesRenameCapabilitiesSchema, z.null()]).optional(), - searchAndReplace: z.union([NesSearchAndReplaceCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ClientNesCapabilities = z.infer - -export const PositionEncodingKindSchema = z.union([ - z.literal('utf-16'), - z.literal('utf-32'), - z.literal('utf-8') -]) -export type PositionEncodingKind = z.infer diff --git a/src/main/acp/generated/protocol.gen.ts b/src/main/acp/generated/protocol.gen.ts deleted file mode 100644 index 1c1cd14183c8..000000000000 --- a/src/main/acp/generated/protocol.gen.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -export * from './protocol-version.gen' -export * from './client-capabilities.gen' -export * from './providers-capabilities.gen' -export * from './nes-context-capabilities.gen' -export * from './mcp-server-acp.gen' -export * from './session-config-option.gen' -export * from './text-resource-contents.gen' -export * from './diff.gen' -export * from './plan-update.gen' -export * from './session-update.gen' -export * from './set-session-model-request.gen' -export * from './wait-for-terminal-exit-request.gen' diff --git a/src/main/acp/generated/providers-capabilities.gen.ts b/src/main/acp/generated/providers-capabilities.gen.ts deleted file mode 100644 index c9864207ed26..000000000000 --- a/src/main/acp/generated/providers-capabilities.gen.ts +++ /dev/null @@ -1,87 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -export const ProvidersCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ProvidersCapabilities = z.infer - -export const NesDocumentDidOpenCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDocumentDidOpenCapabilities = z.infer - -export const TextDocumentSyncKindSchema = z.union([z.literal('full'), z.literal('incremental')]) -export type TextDocumentSyncKind = z.infer - -export const NesDocumentDidChangeCapabilitiesSchema = z.looseObject({ - syncKind: TextDocumentSyncKindSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDocumentDidChangeCapabilities = z.infer< - typeof NesDocumentDidChangeCapabilitiesSchema -> - -export const NesDocumentDidCloseCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDocumentDidCloseCapabilities = z.infer - -export const NesDocumentDidSaveCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDocumentDidSaveCapabilities = z.infer - -export const NesDocumentDidFocusCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDocumentDidFocusCapabilities = z.infer - -export const NesDocumentEventCapabilitiesSchema = z.looseObject({ - didOpen: z.union([NesDocumentDidOpenCapabilitiesSchema, z.null()]).optional(), - didChange: z.union([NesDocumentDidChangeCapabilitiesSchema, z.null()]).optional(), - didClose: z.union([NesDocumentDidCloseCapabilitiesSchema, z.null()]).optional(), - didSave: z.union([NesDocumentDidSaveCapabilitiesSchema, z.null()]).optional(), - didFocus: z.union([NesDocumentDidFocusCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDocumentEventCapabilities = z.infer - -export const NesEventCapabilitiesSchema = z.looseObject({ - document: z.union([NesDocumentEventCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesEventCapabilities = z.infer - -export const NesRecentFilesCapabilitiesSchema = z.looseObject({ - maxCount: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesRecentFilesCapabilities = z.infer - -export const NesRelatedSnippetsCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesRelatedSnippetsCapabilities = z.infer - -export const NesEditHistoryCapabilitiesSchema = z.looseObject({ - maxCount: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesEditHistoryCapabilities = z.infer - -export const NesUserActionsCapabilitiesSchema = z.looseObject({ - maxCount: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesUserActionsCapabilities = z.infer - -export const NesOpenFilesCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesOpenFilesCapabilities = z.infer - -export const NesDiagnosticsCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDiagnosticsCapabilities = z.infer diff --git a/src/main/acp/generated/session-config-option.gen.ts b/src/main/acp/generated/session-config-option.gen.ts deleted file mode 100644 index 3d33a869a6fc..000000000000 --- a/src/main/acp/generated/session-config-option.gen.ts +++ /dev/null @@ -1,113 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { - SessionConfigIdSchema, - SessionConfigOptionCategorySchema, - SessionConfigSelectSchema, - SessionConfigBooleanSchema, - SessionIdSchema, - SessionModeStateSchema, - McpServerSchema -} from './mcp-server-acp.gen' -export const SessionConfigOptionSchema = z.intersection( - z.looseObject({ - id: SessionConfigIdSchema, - name: z.string(), - description: z.union([z.string(), z.null()]).optional(), - category: z.union([SessionConfigOptionCategorySchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() - }), - z.union([ - z.intersection(z.looseObject({ type: z.literal('select') }), SessionConfigSelectSchema), - z.intersection(z.looseObject({ type: z.literal('boolean') }), SessionConfigBooleanSchema) - ]) -) -export type SessionConfigOption = z.infer - -export const NewSessionResponseSchema = z.looseObject({ - sessionId: SessionIdSchema, - modes: z.union([SessionModeStateSchema, z.null()]).optional(), - configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NewSessionResponse = z.infer - -export const LoadSessionRequestSchema = z.looseObject({ - mcpServers: z.array(McpServerSchema), - cwd: z.string(), - additionalDirectories: z.array(z.string()).optional(), - sessionId: SessionIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type LoadSessionRequest = z.infer - -export const LoadSessionResponseSchema = z.looseObject({ - modes: z.union([SessionModeStateSchema, z.null()]).optional(), - configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type LoadSessionResponse = z.infer - -export const ResumeSessionRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - cwd: z.string(), - additionalDirectories: z.array(z.string()).optional(), - mcpServers: z.array(McpServerSchema).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ResumeSessionRequest = z.infer - -export const ResumeSessionResponseSchema = z.looseObject({ - modes: z.union([SessionModeStateSchema, z.null()]).optional(), - configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ResumeSessionResponse = z.infer - -export const RoleSchema = z.union([z.literal('assistant'), z.literal('user')]) -export type Role = z.infer - -export const AnnotationsSchema = z.looseObject({ - audience: z.union([z.array(RoleSchema), z.null()]).optional(), - lastModified: z.union([z.string(), z.null()]).optional(), - priority: z.union([z.number(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Annotations = z.infer - -export const TextContentSchema = z.looseObject({ - annotations: z.union([AnnotationsSchema, z.null()]).optional(), - text: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type TextContent = z.infer - -export const ImageContentSchema = z.looseObject({ - annotations: z.union([AnnotationsSchema, z.null()]).optional(), - data: z.string(), - mimeType: z.string(), - uri: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ImageContent = z.infer - -export const AudioContentSchema = z.looseObject({ - annotations: z.union([AnnotationsSchema, z.null()]).optional(), - data: z.string(), - mimeType: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AudioContent = z.infer - -export const ResourceLinkSchema = z.looseObject({ - annotations: z.union([AnnotationsSchema, z.null()]).optional(), - description: z.union([z.string(), z.null()]).optional(), - mimeType: z.union([z.string(), z.null()]).optional(), - name: z.string(), - size: z.union([z.number().int(), z.null()]).optional(), - title: z.union([z.string(), z.null()]).optional(), - uri: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ResourceLink = z.infer diff --git a/src/main/acp/generated/session-update.gen.ts b/src/main/acp/generated/session-update.gen.ts deleted file mode 100644 index 7b67951065d7..000000000000 --- a/src/main/acp/generated/session-update.gen.ts +++ /dev/null @@ -1,132 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { ContentChunkSchema } from './text-resource-contents.gen' -import { ToolCallSchema, ToolCallUpdateSchema, PlanSchema } from './diff.gen' -import { - PlanUpdateSchema, - PlanRemovedSchema, - AvailableCommandsUpdateSchema, - CurrentModeUpdateSchema, - ConfigOptionUpdateSchema, - SessionInfoUpdateSchema, - UsageUpdateSchema, - CompactionUpdateSchema, - CompactionSummaryChunkSchema -} from './plan-update.gen' -import { SessionIdSchema, SessionModeIdSchema } from './mcp-server-acp.gen' -export const SessionUpdateSchema = z.union([ - z.intersection( - z.looseObject({ sessionUpdate: z.literal('user_message_chunk') }), - ContentChunkSchema - ), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('agent_message_chunk') }), - ContentChunkSchema - ), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('agent_thought_chunk') }), - ContentChunkSchema - ), - z.intersection(z.looseObject({ sessionUpdate: z.literal('tool_call') }), ToolCallSchema), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('tool_call_update') }), - ToolCallUpdateSchema - ), - z.intersection(z.looseObject({ sessionUpdate: z.literal('plan') }), PlanSchema), - z.intersection(z.looseObject({ sessionUpdate: z.literal('plan_update') }), PlanUpdateSchema), - z.intersection(z.looseObject({ sessionUpdate: z.literal('plan_removed') }), PlanRemovedSchema), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('available_commands_update') }), - AvailableCommandsUpdateSchema - ), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('current_mode_update') }), - CurrentModeUpdateSchema - ), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('config_option_update') }), - ConfigOptionUpdateSchema - ), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('session_info_update') }), - SessionInfoUpdateSchema - ), - z.intersection(z.looseObject({ sessionUpdate: z.literal('usage_update') }), UsageUpdateSchema), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('compaction_update') }), - CompactionUpdateSchema - ), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('compaction_summary_chunk') }), - CompactionSummaryChunkSchema - ) -]) -export type SessionUpdate = z.infer - -export const SessionNotificationSchema = z.looseObject({ - sessionId: SessionIdSchema, - update: SessionUpdateSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionNotification = z.infer - -export const PermissionOptionIdSchema = z.string() -export type PermissionOptionId = z.infer - -export const PermissionOptionKindSchema = z.union([ - z.literal('allow_once'), - z.literal('allow_always'), - z.literal('reject_once'), - z.literal('reject_always') -]) -export type PermissionOptionKind = z.infer - -export const PermissionOptionSchema = z.looseObject({ - optionId: PermissionOptionIdSchema, - name: z.string(), - kind: PermissionOptionKindSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PermissionOption = z.infer - -export const RequestPermissionRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - toolCall: ToolCallUpdateSchema, - options: z.array(PermissionOptionSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type RequestPermissionRequest = z.infer - -export const SelectedPermissionOutcomeSchema = z.looseObject({ - optionId: PermissionOptionIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SelectedPermissionOutcome = z.infer - -export const RequestPermissionOutcomeSchema = z.union([ - z.looseObject({ outcome: z.literal('cancelled') }), - z.intersection(z.looseObject({ outcome: z.literal('selected') }), SelectedPermissionOutcomeSchema) -]) -export type RequestPermissionOutcome = z.infer - -export const RequestPermissionResponseSchema = z.looseObject({ - outcome: RequestPermissionOutcomeSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type RequestPermissionResponse = z.infer - -export const SetSessionModeRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - modeId: SessionModeIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SetSessionModeRequest = z.infer - -export const SetSessionModeResponseSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SetSessionModeResponse = z.infer - -export const ModelIdSchema = z.string() -export type ModelId = z.infer diff --git a/src/main/acp/generated/set-session-model-request.gen.ts b/src/main/acp/generated/set-session-model-request.gen.ts deleted file mode 100644 index c25c1f98808e..000000000000 --- a/src/main/acp/generated/set-session-model-request.gen.ts +++ /dev/null @@ -1,121 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { ModelIdSchema } from './session-update.gen' -import { - SessionIdSchema, - SessionConfigIdSchema, - SessionConfigValueIdSchema, - EnvVariableSchema -} from './mcp-server-acp.gen' -import { SessionConfigOptionSchema } from './session-config-option.gen' -import { TerminalIdSchema } from './diff.gen' -export const SetSessionModelRequestSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional(), - modelId: ModelIdSchema, - sessionId: SessionIdSchema -}) -export type SetSessionModelRequest = z.infer - -export const SetSessionModelResponseSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SetSessionModelResponse = z.infer - -export const SetSessionConfigOptionRequestSchema = z.intersection( - z.looseObject({ - sessionId: SessionIdSchema, - configId: SessionConfigIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() - }), - z.union([ - z.looseObject({ value: z.boolean(), type: z.literal('boolean') }), - z.looseObject({ value: SessionConfigValueIdSchema }) - ]) -) -export type SetSessionConfigOptionRequest = z.infer - -export const SetSessionConfigOptionResponseSchema = z.looseObject({ - configOptions: z.array(SessionConfigOptionSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SetSessionConfigOptionResponse = z.infer - -export const ReadTextFileRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - path: z.string(), - line: z.union([z.number().int().min(0), z.null()]).optional(), - limit: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ReadTextFileRequest = z.infer - -export const ReadTextFileResponseSchema = z.looseObject({ - content: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ReadTextFileResponse = z.infer - -export const WriteTextFileRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - path: z.string(), - content: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type WriteTextFileRequest = z.infer - -export const WriteTextFileResponseSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type WriteTextFileResponse = z.infer - -export const CreateTerminalRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - command: z.string(), - args: z.array(z.string()).optional(), - env: z.array(EnvVariableSchema).optional(), - cwd: z.union([z.string(), z.null()]).optional(), - outputByteLimit: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type CreateTerminalRequest = z.infer - -export const CreateTerminalResponseSchema = z.looseObject({ - terminalId: TerminalIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type CreateTerminalResponse = z.infer - -export const TerminalOutputRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - terminalId: TerminalIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type TerminalOutputRequest = z.infer - -export const TerminalExitStatusSchema = z.looseObject({ - exitCode: z.union([z.number().int().min(0), z.null()]).optional(), - signal: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type TerminalExitStatus = z.infer - -export const TerminalOutputResponseSchema = z.looseObject({ - output: z.string(), - truncated: z.boolean(), - exitStatus: z.union([TerminalExitStatusSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type TerminalOutputResponse = z.infer - -export const ReleaseTerminalRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - terminalId: TerminalIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ReleaseTerminalRequest = z.infer - -export const ReleaseTerminalResponseSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ReleaseTerminalResponse = z.infer diff --git a/src/main/acp/generated/text-resource-contents.gen.ts b/src/main/acp/generated/text-resource-contents.gen.ts deleted file mode 100644 index c69e01fb6fb1..000000000000 --- a/src/main/acp/generated/text-resource-contents.gen.ts +++ /dev/null @@ -1,129 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { - AnnotationsSchema, - TextContentSchema, - ImageContentSchema, - AudioContentSchema, - ResourceLinkSchema -} from './session-config-option.gen' -import { SessionIdSchema } from './mcp-server-acp.gen' -export const TextResourceContentsSchema = z.looseObject({ - mimeType: z.union([z.string(), z.null()]).optional(), - text: z.string(), - uri: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type TextResourceContents = z.infer - -export const BlobResourceContentsSchema = z.looseObject({ - blob: z.string(), - mimeType: z.union([z.string(), z.null()]).optional(), - uri: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type BlobResourceContents = z.infer - -export const EmbeddedResourceResourceSchema = z.union([ - TextResourceContentsSchema, - BlobResourceContentsSchema -]) -export type EmbeddedResourceResource = z.infer - -export const EmbeddedResourceSchema = z.looseObject({ - annotations: z.union([AnnotationsSchema, z.null()]).optional(), - resource: EmbeddedResourceResourceSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type EmbeddedResource = z.infer - -export const ContentBlockSchema = z.union([ - z.intersection(z.looseObject({ type: z.literal('text') }), TextContentSchema), - z.intersection(z.looseObject({ type: z.literal('image') }), ImageContentSchema), - z.intersection(z.looseObject({ type: z.literal('audio') }), AudioContentSchema), - z.intersection(z.looseObject({ type: z.literal('resource_link') }), ResourceLinkSchema), - z.intersection(z.looseObject({ type: z.literal('resource') }), EmbeddedResourceSchema) -]) -export type ContentBlock = z.infer - -export const PromptRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - prompt: z.array(ContentBlockSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PromptRequest = z.infer - -export const StopReasonSchema = z.union([ - z.literal('end_turn'), - z.literal('max_tokens'), - z.literal('max_turn_requests'), - z.literal('refusal'), - z.literal('cancelled') -]) -export type StopReason = z.infer - -export const UsageSchema = z.looseObject({ - totalTokens: z.number().int().min(0), - inputTokens: z.number().int().min(0), - outputTokens: z.number().int().min(0), - thoughtTokens: z.union([z.number().int().min(0), z.null()]).optional(), - cachedReadTokens: z.union([z.number().int().min(0), z.null()]).optional(), - cachedWriteTokens: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Usage = z.infer - -export const PromptResponseSchema = z.looseObject({ - stopReason: StopReasonSchema, - usage: z.union([UsageSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PromptResponse = z.infer - -export const CancelNotificationSchema = z.looseObject({ - sessionId: SessionIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type CancelNotification = z.infer - -export const MessageIdSchema = z.string() -export type MessageId = z.infer - -export const ContentChunkSchema = z.looseObject({ - content: ContentBlockSchema, - messageId: z.union([MessageIdSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ContentChunk = z.infer - -export const ToolCallIdSchema = z.string() -export type ToolCallId = z.infer - -export const ToolKindSchema = z.union([ - z.literal('read'), - z.literal('edit'), - z.literal('delete'), - z.literal('move'), - z.literal('search'), - z.literal('execute'), - z.literal('think'), - z.literal('fetch'), - z.literal('switch_mode'), - z.literal('other') -]) -export type ToolKind = z.infer - -export const ToolCallStatusSchema = z.union([ - z.literal('pending'), - z.literal('in_progress'), - z.literal('completed'), - z.literal('failed') -]) -export type ToolCallStatus = z.infer - -export const ContentSchema = z.looseObject({ - content: ContentBlockSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Content = z.infer diff --git a/src/main/acp/generated/wait-for-terminal-exit-request.gen.ts b/src/main/acp/generated/wait-for-terminal-exit-request.gen.ts deleted file mode 100644 index 1f5fc01db7cc..000000000000 --- a/src/main/acp/generated/wait-for-terminal-exit-request.gen.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { SessionIdSchema } from './mcp-server-acp.gen' -import { TerminalIdSchema } from './diff.gen' -export const WaitForTerminalExitRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - terminalId: TerminalIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type WaitForTerminalExitRequest = z.infer - -export const WaitForTerminalExitResponseSchema = z.looseObject({ - exitCode: z.union([z.number().int().min(0), z.null()]).optional(), - signal: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type WaitForTerminalExitResponse = z.infer - -export const KillTerminalRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - terminalId: TerminalIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type KillTerminalRequest = z.infer - -export const KillTerminalResponseSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type KillTerminalResponse = z.infer From a00a327196b3a0b54f29a2c5a7f853cedd780fac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:16:02 -0700 Subject: [PATCH 028/269] fix(native-chat): refuse a stored provider handle written in both forms A typed Claude or Codex handle that also carries the neutral form's transport, agent, native id or provider data named two identities; it was read as Claude or Codex and the next write dropped the other one. Such a row now stays unreadable and is set aside untouched. --- .../agent-session-record-store.test.ts | 23 +++++++++ .../agent-session-provider-handle-encoding.ts | 11 +++++ .../agent-session-record-stored-form.test.ts | 48 ++++++++++++++++++- 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index 078f9b6d0eca..b03317f144d0 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -743,6 +743,29 @@ describe('claim keys and unreadable rows', () => { expect((await open()).isSessionUnreadable('session-alpha')).toBe(true) }) + it('sets aside a row whose handle is in both stored forms and never rewrites it', async () => { + const first = await open() + await establishOwner(first) + let ambiguous: PersistedAgentSessionRecord | undefined + await editPersistedTestAgentSessionStore(directory, (persisted) => { + ambiguous = persisted.records['session-alpha'] + Object.assign(ambiguous.providerHandleChain[0].handle, { + transport: 'acp', + agent: 'grok', + nativeId: 'acp-thread', + providerData: 'resume-token' + }) + }) + const reopened = await open() + expect(reopened.isSessionUnreadable('session-alpha')).toBe(true) + // Another chat's write must carry the set-aside row through untouched. + await establishOwner(reopened, { sessionId: 'session-beta', claimKeyId: 'key-2' }) + expect((await open()).isSessionUnreadable('session-alpha')).toBe(true) + expect((await readPersistedTestAgentSessionStore(directory)).records['session-alpha']).toEqual( + ambiguous + ) + }) + it('refuses to write a store a newer Orca wrote, with the refusal clients print as "update"', async () => { await seedTestAgentSessionStoreFromNewerBuild(directory) const store = await open() diff --git a/src/shared/agent-session-provider-handle-encoding.ts b/src/shared/agent-session-provider-handle-encoding.ts index 77a71a32b14e..da799fa26798 100644 --- a/src/shared/agent-session-provider-handle-encoding.ts +++ b/src/shared/agent-session-provider-handle-encoding.ts @@ -176,12 +176,23 @@ export function encodePersistedAgentSessionProviderHandle( } } +/** The neutral form's discriminator and identity fields; a typed stored handle carries none of them. */ +const NEUTRAL_HANDLE_FIELDS = ['transport', 'agent', 'nativeId', 'providerData'] as const + export function decodePersistedAgentSessionProviderHandle( value: unknown ): AgentSessionProviderHandle | null { if (typeof value !== 'object' || value === null) { return null } + // Why: a row in both forms names two identities; reading it as either would erase the other on + // the next write, so it stays unreadable. + if ( + Object.hasOwn(value, 'provider') && + NEUTRAL_HANDLE_FIELDS.some((field) => Object.hasOwn(value, field)) + ) { + return null + } const provider = 'provider' in value ? value.provider : undefined if (provider === 'claude') { const sessionId = 'sessionId' in value ? value.sessionId : undefined diff --git a/src/shared/agent-session-record-stored-form.test.ts b/src/shared/agent-session-record-stored-form.test.ts index 2107740219e5..3c4381a2f432 100644 --- a/src/shared/agent-session-record-stored-form.test.ts +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -191,8 +191,26 @@ describe('what this build writes', () => { // Claude or Codex in the neutral form: no build writes it. { transport: 'claude-sdk', agent: 'claude', nativeId: 'sess-1' }, { transport: 'codex-app-server', agent: 'codex', nativeId: 'thread-1' }, - // Both forms at once. + // Both forms at once: reading either identity would erase the other. { provider: 'grok', transport: 'acp', agent: 'grok', nativeId: 'x' }, + { + provider: 'codex', + threadId: 'thread-1', + transport: 'acp', + agent: 'grok', + nativeId: 'acp-thread', + providerData: 'resume-token' + }, + { provider: 'codex', threadId: 'thread-1', nativeId: 'other-thread' }, + { + provider: 'claude', + sessionId: 'sess-1', + leafUuid: null, + transport: 'acp', + agent: 'grok', + nativeId: 'other' + }, + { provider: 'claude', sessionId: 'sess-1', leafUuid: null, providerData: 'leaf-2' }, { provider: 'claude', sessionId: 'sess-1' }, { provider: 'claude', sessionId: 'sess-1', leafUuid: '' }, { provider: 'codex', threadId: ' padded ' }, @@ -203,6 +221,34 @@ describe('what this build writes', () => { expect(decodePersistedAgentSessionProviderHandle(value)).toBeNull() } }) + + it('still reads a typed handle that carries an unrelated field', () => { + expect( + decodePersistedAgentSessionProviderHandle({ provider: 'codex', threadId: 't', note: 1 }) + ).toEqual(codexProviderHandle('t')) + expect( + decodePersistedAgentSessionProviderHandle({ + provider: 'claude', + sessionId: 'sess-1', + leafUuid: 'leaf-1', + note: 1 + }) + ).toEqual(claudeProviderHandle('sess-1', 'leaf-1')) + }) + + it.each([ + ['Claude', STORED_CLAUDE_ROW], + ['Codex', STORED_CODEX_ROW] + ])('refuses a whole %s row whose handle also names a neutral identity', (_name, row) => { + const stored = JSON.parse(row) + Object.assign(stored.providerHandleChain[0].handle, { + transport: 'acp', + agent: 'grok', + nativeId: 'acp-thread', + providerData: 'resume-token' + }) + expect(isPersistedAgentSessionRecord(stored)).toBe(false) + }) }) describe('the wire and journal forms', () => { From e8d37ec371a5d95e43fcc1c13f75d044339bc3c4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:16:11 -0700 Subject: [PATCH 029/269] Preserve open ACP decisions through prompt completion --- src/main/acp/acp-session-lifecycle.test.ts | 44 +++++++++++++++++++ .../acp/acp-session-notifications.test.ts | 21 +++++++++ src/main/acp/acp-session-runtime.ts | 29 ++++++++---- 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts index ec79badfbb5f..43f2815697da 100644 --- a/src/main/acp/acp-session-lifecycle.test.ts +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -278,4 +278,48 @@ describe('ACP caller-owned waits', () => { await rejected expect(agent.stdout.readableEnded).toBe(false) }) + it.each(['answer', 'cancel'] as const)( + 'keeps a permission pending after prompt completion until caller %s', + async (action) => { + const decision = deferred() + const entered = deferred() + const { agent, runtime } = fixture({ + onPermission: (_request, context) => { + entered.resolve(context.signal) + return decision.promise + } + }) + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const pending = runtime.prompt([...prompt]) + const response = agent.request('permission', 'session/request_permission', permission) + const signal = await entered.promise + const frame = agent.frames.find((candidate) => candidate.method === 'session/prompt') + if (frame) { + agent.reply(frame, { stopReason: 'end_turn' }) + } + await pending + expect(signal.aborted).toBe(false) + if (action === 'answer') { + decision.resolve({ outcome: { outcome: 'selected', optionId: 'allow' } }) + expect(await response).toMatchObject({ + result: { outcome: { outcome: 'selected', optionId: 'allow' } } + }) + } else { + await runtime.cancel() + expect(await response).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + } + } + ) + + it('confirms cancellation when the agent settles the prompt with an error', async () => { + const { agent, runtime } = fixture() + agent.on('session/prompt', (frame) => { + agent.on('session/cancel', () => agent.fail(frame, -32800, 'Request cancelled')) + }) + await runtime.start(startOptions) + const rejected = expect(runtime.prompt([...prompt])).rejects.toMatchObject({ code: -32800 }) + await expect(runtime.cancel()).resolves.toBeUndefined() + await rejected + }) }) diff --git a/src/main/acp/acp-session-notifications.test.ts b/src/main/acp/acp-session-notifications.test.ts index 181a7cb8dedc..b13796d2ea06 100644 --- a/src/main/acp/acp-session-notifications.test.ts +++ b/src/main/acp/acp-session-notifications.test.ts @@ -77,4 +77,25 @@ describe('ACP session update compatibility', () => { expect(started.response.models?.availableModels[0].modelId).toBe('model-1') expect(started.response.vendor).toBe(true) }) + it('delivers updates even if diagnostic and earlier event callbacks throw', async () => { + const agent = new AcpScriptedAgent() + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, { + onDiagnostic: () => { + throw new Error('Diagnostic failed') + } + }) + opened.push(agent, runtime) + runtime.subscribe(() => { + throw new Error('Listener failed') + }) + const received = vi.fn() + runtime.subscribe(received) + const notification = { sessionId: 'session-1', update: { sessionUpdate: 'vendor' } } + agent.notify('session/update', notification) + expect(received).toHaveBeenCalledWith({ + kind: 'unrecognized', + sessionId: 'session-1', + raw: notification + }) + }) }) diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index bfd0e02226d7..77e317362d13 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -10,8 +10,8 @@ import { type AcpSessionStartOptions } from './acp-session-setup' export type { AcpSessionStarted, AcpSessionStartOptions } from './acp-session-setup' -import { ACP_PROTOCOL_VERSION } from './generated/acp-protocol.generated' import { + ACP_PROTOCOL_VERSION, InitializeResponseSchema, AuthenticateResponseSchema, PromptResponseSchema, @@ -159,7 +159,6 @@ export class AcpSessionRuntime { } this.activePrompt = active active.response = active.response.finally(() => { - this.permissions.cancel(sessionId) if (this.activePrompt === active) { this.activePrompt = undefined } @@ -172,13 +171,13 @@ export class AcpSessionRuntime { return Promise.reject(new Error('ACP session has not started')) } const sessionId = this.started.sessionId + this.permissions.cancel(sessionId) + this.peer.cancelIncomingRequests('session/request_permission') const active = this.activePrompt if (!active) { return Promise.resolve() } active.cancelling = true - this.permissions.cancel(sessionId) - this.peer.cancelIncomingRequests('session/request_permission') active.cancelPromise ??= this.cancelActive(sessionId, active) return active.cancelPromise } @@ -188,7 +187,13 @@ export class AcpSessionRuntime { let timer: ReturnType | undefined try { await Promise.race([ - this.peer.notify('session/cancel', { sessionId }).then(() => active.response), + this.peer.notify('session/cancel', { sessionId }).then(() => + active.response.catch((error) => { + if (this.peer.closed) { + throw error + } + }) + ), new Promise((_resolve, reject) => { timer = setTimeout(() => { const error = new AcpRequestTimeoutError('session/cancel') @@ -276,13 +281,21 @@ export class AcpSessionRuntime { return this.permissions.handle(parsed.data, context, this.options.onPermission) } + private diagnose(message: string): void { + try { + this.options.onDiagnostic?.(message) + } catch { + /* Diagnostics cannot prevent event delivery. */ + } + } + private handleNotification(method: string, params: unknown): void { if (method !== 'session/update') { return } const envelope = updateEnvelopeSchema.safeParse(params) if (!envelope.success) { - this.options.onDiagnostic?.('Ignored invalid ACP session update envelope') + this.diagnose('Ignored invalid ACP session update envelope') return } const parsed = SessionNotificationSchema.safeParse(params) @@ -291,13 +304,13 @@ export class AcpSessionRuntime { : { kind: 'unrecognized', sessionId: envelope.data.sessionId, raw: envelope.data } if (!parsed.success && !this.reportedUpdateAnomaly) { this.reportedUpdateAnomaly = true - this.options.onDiagnostic?.('Forwarded unrecognized ACP session update') + this.diagnose('Forwarded unrecognized ACP session update') } for (const listener of this.listeners) { try { listener(event) } catch (error) { - this.options.onDiagnostic?.(`ACP event listener failed: ${String(error)}`) + this.diagnose(`ACP event listener failed: ${String(error)}`) } } } From 2667173fed98998b8bf49307287cad1561b7414a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:34:54 -0700 Subject: [PATCH 030/269] refactor(native-chat): route structured agents through registered definitions The structured-chat shared layer named Claude and Codex in a closed router type, in option reads, in frame classification, and in renderer label and validation branches, so adding an agent meant editing shared types. Each agent now has a definition (capabilities, resting option rules) owned by its own module; the router is a registry of definition + adapter pairs built at runtime setup, and shared code reads the definition instead of the name. No behavior change for Claude or Codex; no wire or stored shape change. --- ...claude-context-usage-unloaded-turn.test.ts | 12 +- .../claude-structured-agent-definition.ts | 24 +++ .../claude-structured-launch-resolution.ts | 6 +- .../claude-structured-session-adapter.ts | 9 - .../codex-structured-agent-definition.ts | 25 +++ .../codex-structured-launch-resolution.ts | 6 +- .../codex/codex-structured-session-adapter.ts | 3 - .../codex-structured-session-close.test.ts | 4 +- .../agent-model-catalog-fingerprint.ts | 6 +- .../agent-model-catalog-service.ts | 12 +- .../provider-frame-disposition.ts | 63 +++--- .../structured-agent-definition.ts | 37 ++++ ...nt-session-adapter-router-registry.test.ts | 182 ++++++++++++++++++ ...ent-session-adapter-router-test-support.ts | 18 ++ ...tured-agent-session-adapter-router.test.ts | 28 +-- ...structured-agent-session-adapter-router.ts | 106 ++++++---- .../structured-agent-session-adapter.ts | 8 +- ...agent-session-mutation-owed-import.test.ts | 6 +- .../structured-agent-session-options-read.ts | 29 ++- ...ructured-agent-session-provider-support.ts | 10 +- ...uctured-agent-session-repeat-press.test.ts | 3 +- ...ructured-agent-session-thread-goal.test.ts | 33 +++- .../structured-agent-session-thread-goal.ts | 2 +- ...structured-agent-session-create-support.ts | 25 ++- .../structured-agent-session-at-rest.test.ts | 8 +- .../structured-agent-session-runtime.ts | 14 +- .../structured-claude-pending-rewind.test.ts | 12 +- .../NativeChatStructuredSession.tsx | 2 +- .../use-host-model-catalog-upgrade.ts | 3 +- .../use-structured-agent-session-options.ts | 5 +- .../use-structured-agent-session.ts | 2 +- .../structured-agent-session-launch-label.ts | 4 +- ...ctured-agent-session-launch-persistence.ts | 7 +- ...tructured-agent-session-provisional-tab.ts | 8 +- src/shared/agent-session-account-home.test.ts | 37 ++++ src/shared/agent-session-account-home.ts | 45 +++++ src/shared/agent-session-capabilities.ts | 39 ++++ ...ent-session-option-catalog-claude-codex.ts | 1 + .../agent-session-option-catalog-types.ts | 3 + src/shared/agent-session-record.ts | 16 +- .../structured-agent-session-mutation.ts | 12 +- 41 files changed, 682 insertions(+), 193 deletions(-) create mode 100644 src/main/claude/claude-structured-agent-definition.ts create mode 100644 src/main/codex/codex-structured-agent-definition.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-definition.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts create mode 100644 src/shared/agent-session-account-home.test.ts create mode 100644 src/shared/agent-session-account-home.ts create mode 100644 src/shared/agent-session-capabilities.ts diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index 5473719ce114..d98d1e68371e 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -29,6 +29,7 @@ import { import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { CLAUDE_STRUCTURED_AGENT } from './claude-structured-agent-definition' const SESSION = 'orca-session' const journals = createTrackedJournalOpener() @@ -171,7 +172,9 @@ describe('context usage for a turn row outside the loaded page', () => { const state = attachTail(journal, 3) expect(state.hasOlder).toBe(true) expect(hasTurnRow(state)).toBe(false) - const options = await readOptions(journal, { recordsContextUsage: () => true }) + const options = await readOptions(journal, { + capabilities: () => CLAUDE_STRUCTURED_AGENT.capabilities + }) expect(options.contextUsage?.current).toEqual( latestStructuredAgentContextFacts(journal.snapshot().items) @@ -213,7 +216,9 @@ describe('context usage for a turn row outside the loaded page', () => { expect(hasTurnRow(live)).toBe(false) expect(live.unloadedTurnRevisions).toBe(1) - const options = await readOptions(journal, { recordsContextUsage: () => true }) + const options = await readOptions(journal, { + capabilities: () => CLAUDE_STRUCTURED_AGENT.capabilities + }) expect( selectStructuredAgentContextUsage(live.items, options.contextUsage?.current) ).toMatchObject({ usedTokens: 250_000, windowTokens: 1_000_000 }) @@ -227,7 +232,8 @@ describe('context usage for a turn row outside the loaded page', () => { runTurn(translator, 'turn-b', 3_000, []) await settle() const hostAnswer = async () => - (await readOptions(journal, { recordsContextUsage: () => true })).contextUsage?.current + (await readOptions(journal, { capabilities: () => CLAUDE_STRUCTURED_AGENT.capabilities })) + .contextUsage?.current // The client reads the host once per turn, and again whenever its window loses a turn row. let state = attachTail(journal, 200) let host = await hostAnswer() diff --git a/src/main/claude/claude-structured-agent-definition.ts b/src/main/claude/claude-structured-agent-definition.ts new file mode 100644 index 000000000000..ca37f7abfad3 --- /dev/null +++ b/src/main/claude/claude-structured-agent-definition.ts @@ -0,0 +1,24 @@ +import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import { isClaudeStructuredOptionKey } from './claude-structured-options' +import { claudeFallbackModelOptions } from './claude-structured-session-options' + +export const CLAUDE_STRUCTURED_AGENT: StructuredAgentDefinition = { + agent: 'claude', + capabilities: { + // Orca's marker-based rewind proof can never pass on the real binary; rewind returns via a fork. + rewind: false, + compact: true, + threadGoal: false, + // A session at rest still reports the usage its journal recorded. + contextUsage: true, + imagePrompts: true, + steering: 'inject', + // The permission mode is a launch flag the CLI enforces. + approvalEnforcement: 'provider' + }, + restingOptions: { + acceptsKey: isClaudeStructuredOptionKey, + fallbackModels: claudeFallbackModelOptions, + effortDefaultsToModel: true + } +} diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 979b5ae5963e..95e43e2a276b 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -34,6 +34,7 @@ import { resolveClaudeCommand } from '../codex-cli/command' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { agentConfigDirectoryVariable } from '../../shared/agent-session-account-home' export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const export const CLAUDE_SESSION_STATE_EVENTS_ENV = 'CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS' @@ -250,8 +251,9 @@ export function createClaudeStructuredLaunchResolver( `claude structured sessions run on the local host, not ${record.location.executionHostId}` ) } - if (record.accountHome.variable !== 'CLAUDE_CONFIG_DIR') { - throw new Error(`claude sessions pin CLAUDE_CONFIG_DIR, not ${record.accountHome.variable}`) + const pinned = agentConfigDirectoryVariable('claude') + if (record.accountHome.variable !== pinned) { + throw new Error(`claude sessions pin ${pinned}, not ${record.accountHome.variable}`) } // Every acquisition, not just the first: the account state can change under a live session, and // a reacquire after an unexpected exit would otherwise spawn under whatever it has become. diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 746d603308f1..536d4be82395 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -76,12 +76,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda supportsLocation = supportsClaudeStructuredLocation - // Orca's marker-based rewind proof can never pass on the real binary; rewind returns via a fork. - rewindSupport: NonNullable = () => ({ - supported: false, - reason: 'unsupported' - }) - acquire = (input: StructuredAgentSessionAcquireInput): Promise => { this.settledExitErrors.delete(input.identity.sessionId) return acquireClaudeSession({ @@ -254,9 +248,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda ) readOptions = (input: { sessionId: string; fence: number }) => readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) - // Provider-level: a session at rest still reports the usage its journal recorded. - recordsContextUsage = (): boolean => true - readOptionRestoreFailures = (sessionId: string): readonly string[] => [ ...(this.sessions.get(sessionId)?.restoreSkippedOptions ?? []) ] diff --git a/src/main/codex/codex-structured-agent-definition.ts b/src/main/codex/codex-structured-agent-definition.ts new file mode 100644 index 000000000000..8aab5137b114 --- /dev/null +++ b/src/main/codex/codex-structured-agent-definition.ts @@ -0,0 +1,25 @@ +import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import { isCodexTurnOptionKey } from './codex-structured-turn-start' + +export const CODEX_STRUCTURED_AGENT: StructuredAgentDefinition = { + agent: 'codex', + capabilities: { + // A thread with legacy, unpaginated history narrows this to unsupported once it runs. + rewind: true, + compact: true, + // A goal change at rest starts the agent first. + threadGoal: true, + contextUsage: false, + imagePrompts: true, + steering: 'inject', + // Approval and sandbox policy ride on the thread; the app-server enforces them. + approvalEnforcement: 'provider' + }, + restingOptions: { + acceptsKey: isCodexTurnOptionKey, + // No built-in list: the client fills the current model from its own unknown-model defaults. + fallbackModels: () => null, + // A running child answers only the effort its thread reported, never the model's default. + effortDefaultsToModel: false + } +} diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 19945c9a6c89..b48cc4cae113 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -15,6 +15,7 @@ import type { CodexStructuredLaunch } from './codex-structured-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof' import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' +import { agentConfigDirectoryVariable } from '../../shared/agent-session-account-home' export type CodexStructuredLaunchResolverDeps = { store: AgentSessionRecordStore @@ -85,8 +86,9 @@ export function createCodexStructuredLaunchResolver( ) { throw new Error('codex structured sessions require Windows process creation-time proof') } - if (accountHome.variable !== 'CODEX_HOME') { - throw new Error(`codex sessions pin CODEX_HOME, not ${accountHome.variable}`) + const pinned = agentConfigDirectoryVariable('codex') + if (accountHome.variable !== pinned) { + throw new Error(`codex sessions pin ${pinned}, not ${accountHome.variable}`) } const { command, environment } = await resolveCodexStructuredInvocation(deps) // `record.launchArgs` is deliberately not read: the configured CLI arguments are a terminal diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 13a687a72cdc..8cf8466fdd61 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -261,9 +261,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap this.deps.requestTimeoutMs ) - // Provider-level: a goal change at rest starts the agent first. - supportsThreadGoal = (): boolean => true - answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => answerCodexStructuredPrompt({ request, sessions: this.sessions }) diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index cb443a157e02..e9a16a2a3c96 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -16,7 +16,7 @@ import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import { CodexPromptRegistry } from './codex-structured-prompt-replies' import type { CodexSession } from './codex-structured-session-state' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' +import { claudeAndCodexRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const THREAD = 'thread-1' @@ -212,7 +212,7 @@ describe('Codex structured session close lifecycle', () => { it('routes Codex sink-failure recovery through force-close and preserves unexpected-exit settlement', async () => { const { adapter, connections, events } = adapterFixture() - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude: claudeAdapterStub(), codex: adapter }, async () => {} ) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts index 28b1f8729d3c..70c59acd77b4 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts @@ -4,6 +4,8 @@ import type { AgentModelCatalogSessionAccess, AgentModelCatalogStore } from './agent-model-catalog-store' +import { agentConfigDirectoryVariable } from '../../../shared/agent-session-account-home' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' /** * Everything that changes which models a listing can answer with: the agent, @@ -55,7 +57,7 @@ export function agentModelCatalogFingerprintForRecord( * Native only: both structured adapters refuse non-native locations at launch. */ export function agentModelCatalogSessionAccess( store: AgentModelCatalogStore | undefined, - agent: 'claude' | 'codex', + agent: AgentSessionHandleProvider, accountHomePath: string | null ): AgentModelCatalogSessionAccess | undefined { if (!store || !accountHomePath) { @@ -65,7 +67,7 @@ export function agentModelCatalogSessionAccess( store, fingerprint: agentModelCatalogFingerprint({ agent, - accountHomeVariable: agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', + accountHomeVariable: agentConfigDirectoryVariable(agent), accountHomePath, wslDistro: null }), diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index 8c794331a2ce..a8cc70ade7c9 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -1,5 +1,9 @@ import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionAccountHome, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import { agentModelCatalogFingerprint, agentModelCatalogFingerprintForRecord @@ -16,9 +20,7 @@ export type AgentModelCatalogServiceDeps = { /** The account home a structured launch for this agent would pin right now — * the SAME resolver the create path fills `record.accountHome` with, so a * record-less read can never answer from another account's listing. */ - resolveAccountHome: ( - agent: 'claude' | 'codex' - ) => Promise<{ variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME'; path: string }> + resolveAccountHome: (agent: AgentSessionHandleProvider) => Promise /** Session-less listers, one per agent that has one on this host. */ probes?: Partial> /** Whether the workspace's own config could pick a model other than the listed default. */ @@ -96,7 +98,7 @@ export function createAgentModelCatalogService( // Probes spawn natively; a WSL-pinned record has no host-side lister. accountHomePath = scoped.location.wslDistro === null ? scoped.accountHome.path : null } else { - let resolved: { variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME'; path: string } + let resolved: AgentSessionAccountHome try { resolved = await deps.resolveAccountHome(params.agent) } catch { diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index 1b89afd3997e..56c5244e2ff2 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -279,24 +279,44 @@ export function isDeltaProviderFrameKind(kind: string): boolean { return notificationKind(kind).toLowerCase().endsWith('delta') } -function catalogClassification( - provider: string, - kind: string -): ProviderFrameClassification | undefined { - if (provider === 'codex') { - const item = itemKind(kind) - if (item !== null) { - return CODEX_ITEM_CLASSIFICATIONS[item] +const CODEX_NOTIFICATION_CLASSIFICATIONS: Readonly> = + PROVIDER_FRAME_CLASSIFICATIONS.codex +const CLAUDE_FRAME_CLASSIFICATIONS: Readonly> = + PROVIDER_FRAME_CLASSIFICATIONS.claude + +/** Each provider's own table, by provider id: an unlisted provider has none, so every frame it + * sends that no rule above claims stays a visible row. */ +const PROVIDER_FRAME_CATALOGS: ReadonlyMap< + string, + (kind: string, payload: unknown) => ProviderFrameClassification | undefined +> = new Map< + keyof ProviderFrameClassificationTable, + (kind: string, payload: unknown) => ProviderFrameClassification | undefined +>([ + [ + 'codex', + (kind) => { + const item = itemKind(kind) + if (item !== null) { + return CODEX_ITEM_CLASSIFICATIONS[item] + } + return CODEX_NOTIFICATION_CLASSIFICATIONS[notificationKind(kind)] } - return PROVIDER_FRAME_CLASSIFICATIONS.codex[ - notificationKind(kind) as CodexAppServerNotificationMethod - ] - } - if (provider === 'claude') { - return PROVIDER_FRAME_CLASSIFICATIONS.claude[kind as ClaudeStreamJsonFrameKind] - } - return undefined -} + ], + [ + 'claude', + (kind, payload) => { + if (kind === 'message:result') { + const subtype = + typeof payload === 'object' && payload !== null && 'subtype' in payload + ? payload.subtype + : undefined + return subtype === 'success' ? 'status-chrome' : 'error-surface' + } + return CLAUDE_FRAME_CLASSIFICATIONS[kind] + } + ] +]) export function classifyProviderFrame( provider: string, @@ -312,12 +332,5 @@ export function classifyProviderFrame( if (isDeltaProviderFrameKind(kind)) { return 'stream-into-item' } - if (provider === 'claude' && kind === 'message:result') { - const subtype = - typeof payload === 'object' && payload !== null - ? (payload as Record).subtype - : undefined - return subtype === 'success' ? 'status-chrome' : 'error-surface' - } - return catalogClassification(provider, kind) ?? 'timeline-substantive' + return PROVIDER_FRAME_CATALOGS.get(provider)?.(kind, payload) ?? 'timeline-substantive' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts new file mode 100644 index 000000000000..7578c42463c3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts @@ -0,0 +1,37 @@ +// What the host knows about a structured agent before any session of it runs. +// +// Each agent's own module declares its definition; shared host code reads the definition and never +// branches on the agent's name. The router routes sessions to adapters registered with these +// definitions, and at-rest reads with no adapter in hand look one up by agent here. + +import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' +import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' +import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' + +export type StructuredAgentDefinition = { + /** The Orca agent whose sessions this definition describes. */ + agent: string + capabilities: AgentSessionCapabilities + /** How a session's options read and change while no child runs. */ + restingOptions: { + /** Whether the agent takes a pick of this option key. */ + acceptsKey: (key: string) => boolean + /** The models a running child falls back to with no catalog; null when it has none. */ + fallbackModels: () => AgentSessionModelOption[] | null + /** An unpicked effort reads as the model's default effort, as a running child reports it. */ + effortDefaultsToModel: boolean + } +} + +const DEFINITIONS: ReadonlyMap = new Map( + [CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT].map((definition) => [ + definition.agent, + definition + ]) +) + +/** The definition of an agent this build drives in structured sessions; null for any other. */ +export function structuredAgentDefinition(agent: string): StructuredAgentDefinition | null { + return DEFINITIONS.get(agent) ?? null +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts new file mode 100644 index 000000000000..08c77443d7ac --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { NO_AGENT_SESSION_CAPABILITIES } from '../../../shared/agent-session-capabilities' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' +import type { + AgentSessionAcquisition, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import { + structuredAgentDefinition, + type StructuredAgentDefinition +} from './structured-agent-definition' + +const LOCAL: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} + +function identity(sessionId: string, agent: string): AgentSessionJournalIdentity { + return { + sessionId, + workspaceId: 'workspace-1', + hostId: 'local', + agent, + providerHandle: claudeProviderHandle('provider-session-1', null) + } +} + +function acquisition(fence: number, spawnToken: string): AgentSessionAcquisition { + return { + process: { hostId: 'local', pid: 1, processStartTimeMs: 1, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: claudeProviderHandle('provider-session-1', null), + origin: 'created', + mintedAtFence: fence, + observedAt: 1 + } + } +} + +function fakeAdapter( + overrides: Partial = {} +): StructuredAgentSessionAdapter { + return { + supportsLocation: () => true, + acquire: vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn(), + ...overrides + } +} + +/** An agent this build does not ship, declared the way a new adapter would declare itself. */ +const PILOT: StructuredAgentDefinition = { + agent: 'grok', + capabilities: { + ...NO_AGENT_SESSION_CAPABILITIES, + steering: 'queue', + approvalEnforcement: 'orca' + }, + restingOptions: { + acceptsKey: () => false, + fallbackModels: () => null, + effortDefaultsToModel: false + } +} + +describe('StructuredAgentSessionAdapterRouter registry', () => { + it('routes a registered agent the router has no code for, and refuses an unregistered one', async () => { + const pilot = fakeAdapter() + const router = new StructuredAgentSessionAdapterRouter( + [{ definition: PILOT, adapter: pilot }], + async () => {} + ) + + expect(router.supportsCreate(LOCAL, 'grok')).toBe(true) + expect(router.supportsCreate(LOCAL, 'claude')).toBe(false) + await router.acquire({ identity: identity('session-1', 'grok'), fence: 1, spawnToken: 's-1' }) + expect(pilot.acquire).toHaveBeenCalledOnce() + await expect( + router.acquire({ identity: identity('session-2', 'claude'), fence: 1, spawnToken: 's-2' }) + ).rejects.toThrow('structured sessions do not support claude') + }) + + it('refuses two registrations for one agent', () => { + expect( + () => + new StructuredAgentSessionAdapterRouter( + [ + { definition: PILOT, adapter: fakeAdapter() }, + { definition: PILOT, adapter: fakeAdapter() } + ], + async () => {} + ) + ).toThrow('structured agent grok is registered twice') + }) + + it('answers capabilities from the live owner, else from the agent named at rest', async () => { + const router = new StructuredAgentSessionAdapterRouter( + [ + { definition: CLAUDE_STRUCTURED_AGENT, adapter: fakeAdapter() }, + { definition: CODEX_STRUCTURED_AGENT, adapter: fakeAdapter() } + ], + async () => {} + ) + + expect(router.capabilities('session-1')).toBeUndefined() + expect(router.capabilities('session-1', 'codex')).toBe(CODEX_STRUCTURED_AGENT.capabilities) + expect(router.capabilities('session-1', 'grok')).toBeUndefined() + await router.acquire({ identity: identity('session-1', 'claude'), fence: 1, spawnToken: 's' }) + // A live session answers for its own agent, whatever a caller names. + expect(router.capabilities('session-1', 'codex')).toBe(CLAUDE_STRUCTURED_AGENT.capabilities) + }) + + it('lets a session narrow a declared rewind but never widen an undeclared one', () => { + const narrowing = fakeAdapter({ + rewindSupport: () => ({ supported: false, reason: 'history-not-paginated' }) + }) + const widening = fakeAdapter({ rewindSupport: () => ({ supported: true }) }) + const router = new StructuredAgentSessionAdapterRouter( + [ + { definition: CODEX_STRUCTURED_AGENT, adapter: narrowing }, + { definition: CLAUDE_STRUCTURED_AGENT, adapter: widening } + ], + async () => {} + ) + + expect(router.rewindSupport('session-1', 'codex')).toEqual({ + supported: false, + reason: 'history-not-paginated' + }) + expect(router.rewindSupport('session-1', 'claude')).toEqual({ + supported: false, + reason: 'unsupported' + }) + }) +}) + +describe('structured agent definitions', () => { + it('declares what Claude and Codex structured chats already did', () => { + expect(structuredAgentDefinition('claude')?.capabilities).toEqual({ + rewind: false, + compact: true, + threadGoal: false, + contextUsage: true, + imagePrompts: true, + steering: 'inject', + approvalEnforcement: 'provider' + }) + expect(structuredAgentDefinition('codex')?.capabilities).toEqual({ + rewind: true, + compact: true, + threadGoal: true, + contextUsage: false, + imagePrompts: true, + steering: 'inject', + approvalEnforcement: 'provider' + }) + expect(structuredAgentDefinition('grok')).toBeNull() + }) + + it('keeps each agent’s resting option rules with its definition', () => { + const claude = structuredAgentDefinition('claude')!.restingOptions + const codex = structuredAgentDefinition('codex')!.restingOptions + expect(claude.fallbackModels()?.length).toBeGreaterThan(0) + expect(codex.fallbackModels()).toBeNull() + expect(claude.effortDefaultsToModel).toBe(true) + expect(codex.effortDefaultsToModel).toBe(false) + expect(claude.acceptsKey('model')).toBe(true) + expect(codex.acceptsKey('model')).toBe(true) + expect(claude.acceptsKey('no-such-option')).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts new file mode 100644 index 000000000000..b0e39d2c84e8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts @@ -0,0 +1,18 @@ +import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' + +/** A router with this build's two agents registered under their real definitions. */ +export function claudeAndCodexRouter( + adapters: { claude: StructuredAgentSessionAdapter; codex: StructuredAgentSessionAdapter }, + closeAdapters: () => Promise +): StructuredAgentSessionAdapterRouter { + return new StructuredAgentSessionAdapterRouter( + [ + { definition: CLAUDE_STRUCTURED_AGENT, adapter: adapters.claude }, + { definition: CODEX_STRUCTURED_AGENT, adapter: adapters.codex } + ], + closeAdapters + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts index 076e2de7540d..7f8ad5c91067 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -7,7 +7,7 @@ import type { AgentSessionAcquisition, StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import { claudeAndCodexRouter } from './structured-agent-session-adapter-router-test-support' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' function claudeIdentity(sessionId: string): AgentSessionJournalIdentity { @@ -51,7 +51,7 @@ describe('StructuredAgentSessionAdapterRouter.releaseAcquisition', () => { const failure = new Error('root exited') const claude = adapterOf(vi.fn().mockRejectedValueOnce(failure).mockResolvedValue(false)) const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) const identity = claudeIdentity('session-1') await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) @@ -71,7 +71,7 @@ describe('StructuredAgentSessionAdapterRouter.closeSession', () => { claude.closeSession = closeSession claude.dispatch = dispatch const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) const identity = claudeIdentity('session-1') await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) @@ -96,7 +96,7 @@ describe('StructuredAgentSessionAdapterRouter.closeSession', () => { }) const claude = adapterOf(vi.fn(async () => true)) claude.closeSession = closeSession - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude, codex: adapterOf(vi.fn(async () => false)) }, async () => {} ) @@ -129,7 +129,7 @@ describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () => const dispatch = vi.fn().mockResolvedValue({ state: 'unknown', reason: 'test' }) claude.dispatch = dispatch const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) const identity = claudeIdentity('session-1') await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) const stopSession = router[method] @@ -156,7 +156,7 @@ describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () => const claude = adapterOf(vi.fn(async () => true)) claude.closeSession = closeSession const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, @@ -183,7 +183,7 @@ describe('StructuredAgentSessionAdapterRouter.stopEndsSession', () => { const claude = adapterOf(vi.fn(async () => true)) claude.stopEndsSession = () => true const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) expect(router.stopEndsSession('session-1')).toBe(false) await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) @@ -194,7 +194,7 @@ describe('StructuredAgentSessionAdapterRouter.stopEndsSession', () => { const claude = adapterOf(vi.fn(async () => true)) claude.awaitStoppedRequestEnd = vi.fn(async () => undefined) const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) await router.awaitStoppedRequestEnd('session-1', 5) expect(claude.awaitStoppedRequestEnd).not.toHaveBeenCalled() @@ -207,7 +207,7 @@ describe('StructuredAgentSessionAdapterRouter.stopEndsSession', () => { const claude = adapterOf(vi.fn(async () => true)) claude.routePromptCancel = () => ({ kind: 'stop' }) const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) expect(router.routePromptCancel({ sessionId: 'session-1', prompt: QUESTION })).toBeUndefined() await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) @@ -222,7 +222,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { const acquire = vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)) const claude = adapterOf(vi.fn(async () => true)) claude.acquire = acquire - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude, codex: adapterOf(vi.fn(async () => false)) }, async () => undefined ) @@ -241,7 +241,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { it('keeps a per-session stop proof and reports no stop for a session it never routed', async () => { const claude = adapterOf(vi.fn(async () => true)) const closeAdapters = vi.fn(async () => undefined) - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude, codex: adapterOf(vi.fn(async () => false)) }, closeAdapters ) @@ -266,7 +266,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { it('asks the adapters to release an unrouted session rather than answering from the close proof', async () => { const claudeRelease = vi.fn(async () => true) const codexRelease = vi.fn(async () => false) - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude: adapterOf(claudeRelease), codex: adapterOf(codexRelease) }, async () => undefined ) @@ -284,7 +284,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { const closeSession = vi.fn(async () => true) claude.dispatch = dispatch claude.closeSession = closeSession - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude, codex: adapterOf(vi.fn(async () => false)) }, vi.fn(async () => { throw failure @@ -322,7 +322,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { resolveAcquire = resolve }) ) - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude, codex: adapterOf(vi.fn(async () => false)) }, async () => undefined ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index b5d431c859a1..68720023fe65 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -6,41 +6,63 @@ import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentDefinition } from './structured-agent-definition' +import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' -type RoutedAgent = 'claude' | 'codex' -type SessionRoute = { adapter: StructuredAgentSessionAdapter; state: 'live' | 'stopped' } +/** One structured agent this runtime drives: its definition, and the adapter that runs it. */ +export type StructuredAgentRegistration = { + definition: StructuredAgentDefinition + adapter: StructuredAgentSessionAdapter +} + +type SessionRoute = { + registration: StructuredAgentRegistration + state: 'live' | 'stopped' +} +/** Routes each session to the adapter registered for its agent. Adding an agent is one more + * registration; nothing here names one. */ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessionAdapter { private readonly routes = new Map() + private readonly registrations: ReadonlyMap private allAdaptersClosed = false private closePromise: Promise | null = null constructor( - private readonly adapters: Record, + registrations: readonly StructuredAgentRegistration[], private readonly closeAdapters: () => Promise - ) {} + ) { + const byAgent = new Map() + for (const registration of registrations) { + if (byAgent.has(registration.definition.agent)) { + throw new Error(`structured agent ${registration.definition.agent} is registered twice`) + } + byAgent.set(registration.definition.agent, registration) + } + this.registrations = byAgent + } supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => { - const adapter = this.adapterForAgent(agent) + const adapter = this.registrations.get(agent)?.adapter return adapter ? (adapter.supportsLocation?.(location) ?? false) : false } supportsLocation = (location: AgentSessionExecutionLocation): boolean => - Object.values(this.adapters).some((adapter) => adapter.supportsLocation?.(location) ?? false) + this.adapters().some((adapter) => adapter.supportsLocation?.(location) ?? false) - /** Both adapters already gate their own shutdown, so the router only has to stop UNDOING that: + /** Every adapter already gates its own shutdown, so the router only has to stop UNDOING that: * a late acquire must not clear `allAdaptersClosed` and fan a session back out to closed * adapters. Once closed, the router stays closed. */ async acquire(input: Parameters[0]) { if (this.allAdaptersClosed) { throw new Error('structured session adapter router is closed') } - const adapter = this.requireAgent(input.identity) - const acquired = await adapter.acquire(input) + const registration = this.requireAgent(input.identity) + const acquired = await registration.adapter.acquire(input) if (this.allAdaptersClosed) { throw new Error('structured session adapter router is closed') } - this.routes.set(input.identity.sessionId, { adapter, state: 'live' }) + this.routes.set(input.identity.sessionId, { registration, state: 'live' }) return acquired } @@ -48,13 +70,13 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi const route = this.routes.get(input.sessionId) if (route) { try { - return (await route.adapter.releaseAcquisition?.(input)) === true + return (await route.registration.adapter.releaseAcquisition?.(input)) === true } finally { this.routes.delete(input.sessionId) } } let released = false - for (const candidate of Object.values(this.adapters)) { + for (const candidate of this.adapters()) { released = (await candidate.releaseAcquisition?.(input)) === true || released } return released @@ -63,11 +85,17 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) - rewindSupport: NonNullable = (sessionId, agent) => - this.capabilityOwner(sessionId, agent)?.rewindSupport?.(sessionId) ?? { - supported: false, - reason: 'unsupported' + /** The declared capability, narrowed by the adapter's answer for this session. */ + rewindSupport: NonNullable = ( + sessionId, + agent + ) => { + const owner = this.capabilityOwner(sessionId, agent) + if (!owner?.definition.capabilities.rewind) { + return { supported: false, reason: 'unsupported' } } + return owner.adapter.rewindSupport?.(sessionId) ?? { supported: true } + } rewind: NonNullable = (input) => this.owner(input.sessionId).rewind?.(input) ?? @@ -96,11 +124,8 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return change(input) } - supportsThreadGoal = (sessionId: string, agent?: string): boolean => - this.capabilityOwner(sessionId, agent)?.supportsThreadGoal?.(sessionId) ?? false - - recordsContextUsage = (sessionId: string, agent?: string): boolean => - this.capabilityOwner(sessionId, agent)?.recordsContextUsage?.(sessionId) ?? false + capabilities = (sessionId: string, agent?: string): AgentSessionCapabilities | undefined => + this.capabilityOwner(sessionId, agent)?.definition.capabilities stopBackgroundTasks: NonNullable = ( input @@ -132,9 +157,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi this.liveOwnerOrNull(sessionId)?.readCommands?.(sessionId) atRestCommands: StructuredAgentSessionAtRestCommands = { - read: (record) => this.adapters[record.provider].atRestCommands?.read(record), + read: (record) => this.registrations.get(record.provider)?.adapter.atRestCommands?.read(record), onChange: (listener) => { - const stops = Object.values(this.adapters).flatMap((adapter) => + const stops = this.adapters().flatMap((adapter) => adapter.atRestCommands ? [adapter.atRestCommands.onChange(listener)] : [] ) return () => stops.forEach((stop) => stop()) @@ -164,12 +189,14 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi this.owner(sessionId).readOptionRestoreFailures?.(sessionId) ?? [] historyFilePath = (input: { identity: AgentSessionJournalIdentity }) => - this.requireAgent(input.identity).historyFilePath?.(input) ?? Promise.resolve(null) + this.requireAgent(input.identity).adapter.historyFilePath?.(input) ?? Promise.resolve(null) providerHistoryWindow = (input: { identity: AgentSessionJournalIdentity accountHome: AgentSessionAccountHome - }) => this.requireAgent(input.identity).providerHistoryWindow?.(input) ?? Promise.resolve(null) + }) => + this.requireAgent(input.identity).adapter.providerHistoryWindow?.(input) ?? + Promise.resolve(null) closeSession = (sessionId: string): Promise => this.stopSession(sessionId, (adapter) => adapter.closeSession) @@ -196,8 +223,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi if (route.state === 'stopped') { return true } - const stop = selectStop(route.adapter) - const stopped = await stop?.call(route.adapter, sessionId) + const { adapter } = route.registration + const stop = selectStop(adapter) + const stopped = await stop?.call(adapter, sessionId) if (stopped === true) { route.state = 'stopped' return true @@ -243,25 +271,29 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return adapter } - /** The live owner, or for a session at rest the provider it would start under. */ - private capabilityOwner(sessionId: string, agent?: string): StructuredAgentSessionAdapter | null { - return this.liveOwnerOrNull(sessionId) ?? (agent ? this.adapterForAgent(agent) : null) + /** The live owner, or for a session at rest the agent it would start under. */ + private capabilityOwner(sessionId: string, agent?: string): StructuredAgentRegistration | null { + const route = this.routes.get(sessionId) + if (route?.state === 'live') { + return route.registration + } + return agent ? (this.registrations.get(agent) ?? null) : null } private liveOwnerOrNull(sessionId: string): StructuredAgentSessionAdapter | null { const route = this.routes.get(sessionId) - return route?.state === 'live' ? route.adapter : null + return route?.state === 'live' ? route.registration.adapter : null } - private requireAgent(identity: AgentSessionJournalIdentity): StructuredAgentSessionAdapter { - const adapter = this.adapterForAgent(identity.agent) - if (!adapter) { + private requireAgent(identity: AgentSessionJournalIdentity): StructuredAgentRegistration { + const registration = this.registrations.get(identity.agent) + if (!registration) { throw new Error(`structured sessions do not support ${identity.agent}`) } - return adapter + return registration } - private adapterForAgent(agent: string): StructuredAgentSessionAdapter | null { - return agent === 'claude' || agent === 'codex' ? this.adapters[agent] : null + private adapters(): StructuredAgentSessionAdapter[] { + return [...this.registrations.values()].map((registration) => registration.adapter) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 318bada1fe52..eaa18ca334d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -47,6 +47,7 @@ import type { AgentSessionPromptResponse } from '../../../shared/agent-session-q import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' +import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' export class AgentSessionAcquisitionRefusal extends Error { readonly code = 'agent_session_operation_invalid' @@ -319,10 +320,9 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & * start a new goal rather than rewrite that one's objective in place. */ replacesGoal: boolean }): Promise<{ ok: true } | { ok: false; rejected: string }> - /** Whether this session can change its goal; `agent` answers one at rest. */ - supportsThreadGoal?(sessionId: string, agent?: string): boolean - /** Whether this session writes context facts to its turn rows; `agent` answers one at rest. */ - recordsContextUsage?(sessionId: string, agent?: string): boolean + /** What this session's agent declares it can do; `agent` answers one at rest. Answered by the + * router from the agent's registered definition, never by a single adapter. */ + capabilities?(sessionId: string, agent?: string): AgentSessionCapabilities | undefined /** Stops exactly the tasks `taskIds` names, which the host resolves from its child records. */ stopBackgroundTasks?(input: { sessionId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts index 7e9ffcd2ac2b..b995b1a373fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts @@ -18,6 +18,7 @@ import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD } from './structured-agent-session-host-test-data' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' let host: StructuredAgentSessionHost let acquire: Mock @@ -89,7 +90,10 @@ describe('a mutation while an import is owed', () => { it('a goal set sees the goal and the turn the provider wrote, and replaces it in that turn', async () => { await attach() const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - Object.assign(host.deps.adapter, { changeThreadGoal, supportsThreadGoal: () => true }) + Object.assign(host.deps.adapter, { + changeThreadGoal, + capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + }) const owed = oweImport() providerOpensTurn('turn-g', 901) providerEvents().appendItem( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index e17d5ddf074a..929f99845ed0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -12,12 +12,10 @@ import { type AgentSessionOptionsResult } from '../../../shared/agent-session-wire' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { claudeFallbackModelOptions } from '../../claude/claude-structured-session-options' import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' -import { isClaudeStructuredOptionKey } from '../../claude/claude-structured-options' -import { isCodexTurnOptionKey } from '../../codex/codex-structured-turn-start' +import { structuredAgentDefinition } from './structured-agent-definition' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { structuredAgentSessionOptionModels } from './structured-agent-session-option-models' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -25,13 +23,11 @@ import type { StructuredAgentSessionMutationContext } from './structured-agent-s type RestingOptions = Pick -/** With no catalog for the account, the list a running child falls back to: Claude's built-in - * models. A Codex child has no such list, so none: the client fills the current model from its - * own unknown-model defaults, unchanged from before this list was shared. */ +/** With no catalog for the account, the list a running child of this agent falls back to. */ function restingFallbackModels( provider: AgentSessionRecord['provider'] ): AgentSessionModelOption[] | null { - return provider === 'claude' ? claudeFallbackModelOptions() : null + return structuredAgentDefinition(provider)?.restingOptions.fallbackModels() ?? null } async function readStructuredAgentSessionOptionsAtRest( @@ -59,11 +55,10 @@ async function readStructuredAgentSessionOptionsAtRest( saved.model ?? (catalog.origin === 'unknown' ? undefined : models.find((entry) => entry.isDefault)?.id) ?? '' - // As a live child answers: the pick, else what Claude runs for this model when none is sent. - // A live Codex child answers only the effort its thread reported, never the model's default. + // As a live child answers: the pick, else the model's default where the agent reports that. const effort = saved.effort ?? - (record.provider === 'claude' + (structuredAgentDefinition(record.provider)?.restingOptions.effortDefaultsToModel ? models.find((entry) => entry.id === model)?.defaultEffort : undefined) return { @@ -87,9 +82,8 @@ export async function recordStructuredAgentSessionOptionIntent( ): Promise> { const record = store.getRecord(ctx.sessionId) const accepted = - record?.provider === 'codex' - ? isCodexTurnOptionKey(input.key) - : record?.provider === 'claude' && isClaudeStructuredOptionKey(input.key) + record !== undefined && + structuredAgentDefinition(record.provider)?.restingOptions.acceptsKey(input.key) === true if (!record || !accepted) { return { ok: false, @@ -133,6 +127,7 @@ export async function readStructuredAgentSessionOptions( const session = await context.conversation(sessionId) const phase = store.getRecord(sessionId)?.rewind?.phase const agent = session.params.provider + const capabilities = adapter.capabilities?.(sessionId, agent) return { ...options, rewind: @@ -142,11 +137,9 @@ export async function readStructuredAgentSessionOptions( supported: false, reason: 'unsupported' }), - conversationCommands: adapter.compact ? ['clear', 'compact'] : ['clear'], - ...(adapter.supportsThreadGoal?.(sessionId, agent) - ? { threadGoal: { current: session.journal.threadGoal() } } - : {}), - ...(adapter.recordsContextUsage?.(sessionId, agent) + conversationCommands: capabilities?.compact ? ['clear', 'compact'] : ['clear'], + ...(capabilities?.threadGoal ? { threadGoal: { current: session.journal.threadGoal() } } : {}), + ...(capabilities?.contextUsage ? { contextUsage: { current: session.journal.contextUsage() } } : {}) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts index 15af150c12f1..ce07eeb65a72 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts @@ -12,10 +12,8 @@ export function adapterSupportsCreate( if (adapter.supportsCreate) { return adapter.supportsCreate(location, agent) } - if (agent !== 'codex') { - return false - } - // Older Codex adapters exposed only location support; absence still fails closed here. + // An adapter with no per-agent gate serves the one agent it was built for, so only its location + // support can refuse; absence still fails closed here. return adapter.supportsLocation?.(location) ?? false } @@ -38,6 +36,6 @@ export function adapterSupportsRecord( if (adapter.supportsCreate) { return adapter.supportsCreate(record.location, record.provider) } - // Old Codex records stay readable unless the adapter explicitly rejects their location. - return record.provider === 'codex' && (adapter.supportsLocation?.(record.location) ?? true) + // A record stays readable unless the adapter explicitly rejects its location. + return adapter.supportsLocation?.(record.location) ?? true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts index 6fc7b6c2d52a..97e7e359f6aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts @@ -24,6 +24,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' const CALLER = { callerKey: 'client-1' } @@ -65,7 +66,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption, changeThreadGoal, - supportsThreadGoal: () => true, + capabilities: () => CODEX_STRUCTURED_AGENT.capabilities, stopBackgroundTasks }, journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index 5ab883380d54..5a95d7c90b21 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -18,6 +18,8 @@ import { import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' +import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -88,7 +90,10 @@ describe('performThreadGoalChange', () => { const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) const result = await performThreadGoalChange( - context(journal, { changeThreadGoal, supportsThreadGoal: () => true }), + context(journal, { + changeThreadGoal, + capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + }), { clientOperationId: 'op-1', change: { kind: 'set', objective: 'Ship the parser' } } ) @@ -117,7 +122,7 @@ describe('performThreadGoalChange', () => { const journal = await openJournal() const ctx = context(journal, { changeThreadGoal: async () => ({ ok: false, rejected: 'goals feature is disabled' }), - supportsThreadGoal: () => true + capabilities: () => CODEX_STRUCTURED_AGENT.capabilities }) const result = await performThreadGoalChange(ctx, { @@ -142,7 +147,7 @@ describe('performThreadGoalChange', () => { changeThreadGoal: async () => { throw new Error('connection closed') }, - supportsThreadGoal: () => true + capabilities: () => CODEX_STRUCTURED_AGENT.capabilities }) await expect( @@ -164,7 +169,7 @@ describe('performThreadGoalChange', () => { ] const ctx = context(journal, { changeThreadGoal: () => attempts.shift()!(), - supportsThreadGoal: () => true + capabilities: () => CODEX_STRUCTURED_AGENT.capabilities }) const input = { clientOperationId: 'op-9', @@ -192,7 +197,10 @@ describe('performThreadGoalChange', () => { it('journals nothing for a status change or clear', async () => { const journal = await openJournal() const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - const ctx = context(journal, { changeThreadGoal, supportsThreadGoal: () => true }) + const ctx = context(journal, { + changeThreadGoal, + capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + }) await performThreadGoalChange(ctx, { clientOperationId: 'op-3', @@ -209,7 +217,10 @@ describe('performThreadGoalChange', () => { const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) const result = await performThreadGoalChange( - context(journal, { changeThreadGoal, supportsThreadGoal: () => false }), + context(journal, { + changeThreadGoal, + capabilities: () => CLAUDE_STRUCTURED_AGENT.capabilities + }), { clientOperationId: 'op-5', change: { kind: 'set', objective: 'Ship it' } } ) @@ -232,7 +243,10 @@ describe('performThreadGoalChange', () => { const journal = await openJournal() await appendGoalRow(journal, { status: 'complete' }) const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - const ctx = context(journal, { changeThreadGoal, supportsThreadGoal: () => true }) + const ctx = context(journal, { + changeThreadGoal, + capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + }) await performThreadGoalChange(ctx, { clientOperationId: 'op-6', @@ -262,7 +276,10 @@ describe('performThreadGoalChange', () => { it('reads a goal the provider reported, still landing when the set arrives, as the one it replaces', async () => { const journal = await openJournal() const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - const ctx = context(journal, { changeThreadGoal, supportsThreadGoal: () => true }) + const ctx = context(journal, { + changeThreadGoal, + capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + }) // Issued, not yet landed: the set's read takes its place behind it in the journal's queue. const landing = appendGoalRow(journal, { status: 'active' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index f1612e03db2c..1ab4e5cfaa6e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -47,7 +47,7 @@ export async function performThreadGoalChange( ctx: AgentSessionTurnContext, input: { clientOperationId: string; change: AgentSessionThreadGoalChange } ): Promise> { - if (!ctx.adapter.changeThreadGoal || !ctx.adapter.supportsThreadGoal?.(ctx.sessionId)) { + if (!ctx.adapter.changeThreadGoal || !ctx.adapter.capabilities?.(ctx.sessionId)?.threadGoal) { return refused('goalsUnsupported', 'Goals are unavailable for this chat session.') } const { change } = input diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 2bf67f0b4f88..25a042051609 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -24,6 +24,11 @@ import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { parseWslUncPath } from '../../shared/wsl-paths' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { applyStructuredCodexWorkspaceTrust } from '../agent-workspace-trust-spawn' +import { + agentSessionAccountHome, + type AgentSessionAccountHome +} from '../../shared/agent-session-account-home' +import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle' export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { async getStructuredAgentSessionCreateSupport( @@ -125,31 +130,31 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR * Same resolver as the create intent above — never a second copy. */ async resolveStructuredAgentAccountHome( - agent: 'claude' | 'codex' - ): Promise<{ variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME'; path: string }> { + agent: AgentSessionHandleProvider + ): Promise { const launchEnv = resolveTuiAgentLaunchEnv( agent, this.requireStore().getSettings().agentDefaultEnv ) if (agent === 'claude') { - return { - variable: 'CLAUDE_CONFIG_DIR', - path: resolveStructuredClaudeAccountHomePath({ + return agentSessionAccountHome( + agent, + resolveStructuredClaudeAccountHomePath({ launchEnv, wslDistro: null, getClaudeConfigDirectory: (target) => this.accounts.getClaudeConfigDirectory(target) }) - } + ) } - return { - variable: 'CODEX_HOME', + return agentSessionAccountHome( + agent, // Read-only resolver, never launch prep: a picker mount or discovery read // must not sync homes, start bridges, or clear an account selection. - path: await resolveStructuredCodexAccountHomePath({ + await resolveStructuredCodexAccountHomePath({ launchEnv, resolveLaunchHome: this.resolveCodexStructuredLaunchHomeFn }) - } + ) } protected async resolveStructuredAgentSessionIntent( diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index b846be96dd22..1f70438af2d2 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -32,6 +32,8 @@ import { openTestJournalHostDatabase, updateTestJournalRowJson } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { CODEX_STRUCTURED_AGENT } from '../../../codex/codex-structured-agent-definition' +import { CLAUDE_STRUCTURED_AGENT } from '../../../claude/claude-structured-agent-definition' const CLIENT = { clientId: 'device-1', @@ -370,8 +372,10 @@ describe('options at rest', () => { it('answers the provider-level features of a chat at rest (P2-17)', async () => { await restingChat() Object.assign(rig.host.deps.adapter, { - supportsThreadGoal: (_id: string, agent?: string) => agent === 'codex', - recordsContextUsage: (_id: string, agent?: string) => agent === 'claude', + capabilities: (_id: string, agent?: string) => + agent === 'codex' + ? CODEX_STRUCTURED_AGENT.capabilities + : CLAUDE_STRUCTURED_AGENT.capabilities, rewindSupport: (_id: string, agent?: string) => agent === 'codex' ? { supported: true } : { supported: false, reason: 'unsupported' } }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 3785fdeaf5ce..49665fd27c39 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -27,6 +27,8 @@ import { type CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' +import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' +import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' import { StructuredAgentSessionHost, type StructuredAgentSessionHostDeps @@ -301,9 +303,15 @@ async function installOnJournal( ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), modelCatalog: agentModelCatalogStore }) - const adapter = new StructuredAgentSessionAdapterRouter({ codex, claude }, async () => { - await Promise.all([codex.closeAll(), claude.closeAll()]) - }) + const adapter = new StructuredAgentSessionAdapterRouter( + [ + { definition: CODEX_STRUCTURED_AGENT, adapter: codex }, + { definition: CLAUDE_STRUCTURED_AGENT, adapter: claude } + ], + async () => { + await Promise.all([codex.closeAll(), claude.closeAll()]) + } + ) host = new StructuredAgentSessionHost({ store, adapter, diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index 47fa6d201009..4e29e628d36d 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -11,7 +11,8 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import type { AgentSessionRecord } from '../../shared/agent-session-record' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' import { fakeClaude } from '../claude/claude-structured-session-test-support' -import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' +import { claudeAndCodexRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { HOST_TEST_NOW, @@ -125,9 +126,7 @@ beforeEach(async () => { logger: log.logger, store, // Only Claude sessions are attached here; the router supplies the production create gate. - adapter: new StructuredAgentSessionAdapterRouter({ claude: adapter, codex: adapter }, () => - adapter.closeAll() - ), + adapter: claudeAndCodexRouter({ claude: adapter, codex: adapter }, () => adapter.closeAll()), journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', now: () => HOST_TEST_NOW, @@ -145,10 +144,7 @@ afterEach(async () => { describe('Claude rewind is unsupported', () => { it('refuses a rewind RPC before writing any rewind record', async () => { - expect(adapter.rewindSupport(HOST_TEST_SESSION)).toEqual({ - supported: false, - reason: 'unsupported' - }) + expect(CLAUDE_STRUCTURED_AGENT.capabilities.rewind).toBe(false) expect(await host.rewind(caller, rewindParams(fence()))).toMatchObject({ ok: false, refusal: { rewindReason: 'unsupported' } diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index eca921ed086c..6c2b20b6b1a3 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -123,7 +123,7 @@ export function NativeChatStructuredSession( const retryDelivery = useCallback((clientMessageId: string) => { retryRef.current(clientMessageId) }, []) - const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude') + const agentLabel = structuredAgentLabel(props.agent) // Only a rejected message reads the journal's rows, so a new batch of them re-renders no row else. const hasRejected = controller.outbox.some((entry) => entry.state === 'rejected') const rejectionRows = hasRejected ? controller.submissions : NO_SUBMISSIONS diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts index 010cd8a68d5a..697c380b8270 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts @@ -9,6 +9,7 @@ import { import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import type { NativeChatSessionOptionRecord } from '../../../../shared/native-chat-session-option-state' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' /** * Upgrades the static seed with the host's stored catalog without waiting on @@ -47,7 +48,7 @@ export function useHostModelCatalogUpgrade(args: { worktree } = args useEffect(() => { - if (!enabled || !optionCatalog || (agent !== 'claude' && agent !== 'codex')) { + if (!enabled || !optionCatalog || !isAgentSessionHandleProvider(agent)) { return } let stale = false diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index e3ee7cd1de05..549d5ca3ff7f 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -95,9 +95,8 @@ export function useStructuredAgentSessionOptions(args: { target, optionCatalog, enabled: args.isVisible, - // A resumed conversation may keep its own model, so only a new one runs the listed default — - // and only Codex's listing names the configured model; Claude's settings or env may pick another. - namesDefault: launch?.kind === 'new' && agent === 'codex', + // A resumed conversation may keep its own model, so only a new one runs the listed default. + namesDefault: launch?.kind === 'new' && optionCatalog?.hostListingNamesConfiguredModel === true, ...(launch?.worktree ? { worktree: launch.worktree } : {}), fence, activeOptionRecordRef, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 96ad805560d9..157893a8287d 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -182,7 +182,7 @@ export function useStructuredAgentSession(args: { runConversationCommand: (command: AgentSessionConversationCommand) => structuredConversationCommands.sendStructuredConversationCommand({ command, - agentName: structuredAgentLabel(agent === 'codex' ? 'codex' : 'claude'), + agentName: structuredAgentLabel(agent), pending: commandPending, blocked: Boolean( transportState.turnId || diff --git a/src/renderer/src/lib/structured-agent-session-launch-label.ts b/src/renderer/src/lib/structured-agent-session-launch-label.ts index 2c733aebfa53..2ccea1158bb3 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-label.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-label.ts @@ -1,6 +1,6 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { AgentType } from '../../../shared/agent-status-types' import { getAgentCatalog } from '@/lib/agent-catalog' -export function structuredAgentLabel(agent: AgentSessionHandleProvider): string { +export function structuredAgentLabel(agent: AgentType): string { return getAgentCatalog().find((entry) => entry.id === agent)?.label ?? agent } diff --git a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts index 85d6bf20ed99..0cea968dd430 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts @@ -1,4 +1,7 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import { + isAgentSessionHandleProvider, + type AgentSessionHandleProvider +} from '../../../shared/agent-session-provider-handle' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' export type StructuredAgentLaunchPersistedLifecycle = 'pending' | 'visibility-unknown' | 'failed' @@ -45,7 +48,7 @@ function validRecord(value: unknown): value is StructuredAgentLaunchPersistedRec return ( typeof sessionId === 'string' && sessionId.length > 0 && - (agent === 'claude' || agent === 'codex') && + isAgentSessionHandleProvider(agent) && (lifecycle === 'pending' || lifecycle === 'visibility-unknown' || lifecycle === 'failed') && typeof clientOperationId === 'string' && typeof payloadFingerprint === 'string' && diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts index 0dd4e4939e9e..668738503766 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -11,13 +11,17 @@ import type { StructuredAgentLaunchHooks } from '@/lib/structured-agent-launch-settlement' import { useAppStore } from '@/store' +import { + isAgentSessionHandleProvider, + type AgentSessionHandleProvider +} from '../../../shared/agent-session-provider-handle' export type StructuredAgentSessionProvisionalLaunch = StructuredAgentLaunchHandle & { tab: Tab } export function openStructuredAgentSessionProvisionalTab(args: { worktreeId: string sessionId: string - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider targetGroupId?: string activate?: boolean }): Tab { @@ -67,7 +71,7 @@ export function beginStructuredAgentSessionProvisionalLaunch(args: { return null } const worktreeId = args.target?.worktreeId ?? args.plan.worktreeId - if (!worktreeId || (args.plan.agent !== 'claude' && args.plan.agent !== 'codex')) { + if (!worktreeId || !isAgentSessionHandleProvider(args.plan.agent)) { throw new Error('A provisional structured launch needs its workspace and provider.') } try { diff --git a/src/shared/agent-session-account-home.test.ts b/src/shared/agent-session-account-home.test.ts new file mode 100644 index 000000000000..047f88f19b30 --- /dev/null +++ b/src/shared/agent-session-account-home.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { + agentConfigDirectoryVariable, + agentSessionAccountHome, + isAgentConfigDirectoryVariable +} from './agent-session-account-home' +import { isPersistedAgentSessionRecord } from './agent-session-record' +import { agentSessionRecordFixture } from './agent-session-record.test-fixture' +import { encodeAgentSessionRecord } from './agent-session-record-stored-form' + +describe('agent session account home', () => { + it('pins the variables older builds wrote and read', () => { + expect(agentConfigDirectoryVariable('claude')).toBe('CLAUDE_CONFIG_DIR') + expect(agentConfigDirectoryVariable('codex')).toBe('CODEX_HOME') + expect(JSON.stringify(agentSessionAccountHome('codex', '/home/dev/.codex'))).toBe( + '{"variable":"CODEX_HOME","path":"/home/dev/.codex"}' + ) + }) + + it('admits only a variable some agent declares', () => { + expect(isAgentConfigDirectoryVariable('CLAUDE_CONFIG_DIR')).toBe(true) + expect(isAgentConfigDirectoryVariable('CODEX_HOME')).toBe(true) + expect(isAgentConfigDirectoryVariable('PATH')).toBe(false) + expect(isAgentConfigDirectoryVariable(undefined)).toBe(false) + }) + + it('refuses a stored record whose account home names an undeclared variable', () => { + const record = encodeAgentSessionRecord(agentSessionRecordFixture()) + expect(isPersistedAgentSessionRecord(record)).toBe(true) + expect( + isPersistedAgentSessionRecord({ + ...record, + accountHome: { variable: 'LD_PRELOAD', path: '/tmp/x' } + }) + ).toBe(false) + }) +}) diff --git a/src/shared/agent-session-account-home.ts b/src/shared/agent-session-account-home.ts new file mode 100644 index 000000000000..2a9be3d1f12a --- /dev/null +++ b/src/shared/agent-session-account-home.ts @@ -0,0 +1,45 @@ +/** + * The account an agent session is pinned to: the agent's config directory, and the environment + * variable that points the agent at it. + * + * The variable is the agent's own (Claude reads `CLAUDE_CONFIG_DIR`, Codex `CODEX_HOME`), so a + * record stores it beside the path and a new agent names its own here. Stored values are exactly + * what older builds wrote and read. + */ + +import type { AgentSessionHandleProvider } from './agent-session-provider-handle' + +/** Account root pinned at launch by the account selector, so a resume cannot drift to another login. */ +export type AgentSessionAccountHome = { + /** Environment variable naming the agent's config directory. */ + variable: string + /** Host-resolved absolute path in the execution host's own path syntax. */ + path: string +} + +const CONFIG_DIRECTORY_VARIABLES: Readonly> = { + claude: 'CLAUDE_CONFIG_DIR', + codex: 'CODEX_HOME' +} + +const KNOWN_CONFIG_DIRECTORY_VARIABLES: ReadonlySet = new Set( + Object.values(CONFIG_DIRECTORY_VARIABLES) +) + +/** The variable an account home of this agent pins. */ +export function agentConfigDirectoryVariable(agent: AgentSessionHandleProvider): string { + return CONFIG_DIRECTORY_VARIABLES[agent] +} + +/** The account home of `agent` at `path`. */ +export function agentSessionAccountHome( + agent: AgentSessionHandleProvider, + path: string +): AgentSessionAccountHome { + return { variable: agentConfigDirectoryVariable(agent), path } +} + +/** Admits only a variable some agent declares: a record's variable becomes a child's environment. */ +export function isAgentConfigDirectoryVariable(value: unknown): value is string { + return typeof value === 'string' && KNOWN_CONFIG_DIRECTORY_VARIABLES.has(value) +} diff --git a/src/shared/agent-session-capabilities.ts b/src/shared/agent-session-capabilities.ts new file mode 100644 index 000000000000..64a8b0b0a322 --- /dev/null +++ b/src/shared/agent-session-capabilities.ts @@ -0,0 +1,39 @@ +/** + * What a structured agent can do, declared before any session runs. + * + * Each agent's definition declares one record, and readers branch on it instead of on the agent's + * name. A running session may narrow a declared capability (a Codex thread with legacy history + * cannot rewind) but never widens one. Plain data, so a host can send it to clients as it is. + */ +export type AgentSessionCapabilities = { + /** Roll the conversation back to before a turn. */ + rewind: boolean + /** Compact the conversation's context on request: offers the `/compact` command. */ + compact: boolean + /** A goal the user can set and change on the thread. */ + threadGoal: boolean + /** Turns report how much of the context window they used. */ + contextUsage: boolean + /** Prompts may carry images. */ + imagePrompts: boolean + /** A message sent while a turn runs: handed to the running agent at once (`inject`), or held by + * Orca and sent as the next prompt once the turn ends (`queue`). */ + steering: 'inject' | 'queue' + /** + * Who applies the user's permission setting. `provider`: the agent is launched with it and + * confines its own work. `orca`: the agent asks and Orca answers by the setting, so only what it + * asks about is covered, never a sandbox. + */ + approvalEnforcement: 'provider' | 'orca' +} + +/** The answer for an agent no definition covers: nothing beyond a plain chat, claimed by no one. */ +export const NO_AGENT_SESSION_CAPABILITIES: AgentSessionCapabilities = Object.freeze({ + rewind: false, + compact: false, + threadGoal: false, + contextUsage: false, + imagePrompts: false, + steering: 'queue', + approvalEnforcement: 'orca' +}) diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index b7e9d0e6bed1..8ebd710aeadf 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -213,6 +213,7 @@ function codexEffort(ceiling: 'xhigh' | 'max' | 'ultra'): CatalogOption { export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { supportsWorkerLaunchPreferences: true, + hostListingNamesConfiguredModel: true, // Why: Codex model access depends on auth. Keep this seed short and allow // unknown persisted ids to pass through instead of claiming a complete list. models: [ diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index 74e488e5549a..34f7884b6c13 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -77,6 +77,9 @@ export type AgentSessionOptionCatalog = { * model while the picker, which never reads launch args, still names the CLI default. * A real fix means threading `modelApply.agentArgsOverride` through to the surface. */ defaultModelIsCliDefault?: true + /** The host's model listing names the model the account is configured to run, so a new chat + * may run the listed default. Off where the agent's own settings or env may pick another. */ + hostListingNamesConfiguredModel?: true listModels?: { command: string parse: (stdout: string) => CatalogModel[] diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 963d79b14699..5022b8f0c0b5 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -26,6 +26,12 @@ import { type AgentSessionProviderHandleLink } from './agent-session-provider-handle' import { agentSessionProviderHandleBelongsTo } from './agent-session-provider-handle-encoding' +import { + isAgentConfigDirectoryVariable, + type AgentSessionAccountHome +} from './agent-session-account-home' + +export type { AgentSessionAccountHome } from './agent-session-account-home' export const AGENT_SESSION_RECORD_SCHEMA_VERSION = 2 as const @@ -44,13 +50,6 @@ export type AgentSessionExecutionLocation = { workspaceKind: AgentSessionWorkspaceKind } -/** Account root pinned at launch by the account selector, so a resume cannot drift to another login. */ -export type AgentSessionAccountHome = { - variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME' - /** Host-resolved absolute path in the execution host's own path syntax. */ - path: string -} - /** Provider launch environment captured by the host when the session is created. */ export type AgentSessionLaunchEnv = Record @@ -233,8 +232,7 @@ function isAgentSessionAccountHome(value: unknown): value is AgentSessionAccount } const home = value as Partial return ( - (home.variable === 'CLAUDE_CONFIG_DIR' || home.variable === 'CODEX_HOME') && - isBoundedString(home.path, MAX_PATH_LENGTH) + isAgentConfigDirectoryVariable(home.variable) && isBoundedString(home.path, MAX_PATH_LENGTH) ) } diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index 4c25de1a1bdc..16f6f4f45831 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -1,4 +1,8 @@ import { sha256 } from './sha256' +import { + isAgentSessionHandleProvider, + type AgentSessionHandleProvider +} from './agent-session-provider-handle' function canonicalize(value: unknown): string { if (value === null || typeof value !== 'object') { @@ -41,7 +45,7 @@ export function structuredAgentSessionDomainFingerprint(input: { export function structuredAgentSessionCreateFingerprint(input: { sessionId: string worktree: string - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider resumeFrom?: { providerSessionId: string } tabId?: string }): string { @@ -66,11 +70,7 @@ export function showStructuredAgentSessionChoice(input: { workspaceSupport: boolean agent: string }): boolean { - return ( - input.hostCapability && - input.workspaceSupport && - (input.agent === 'claude' || input.agent === 'codex') - ) + return input.hostCapability && input.workspaceSupport && isAgentSessionHandleProvider(input.agent) } export function createStructuredAgentSessionOperationId( From 63508a51c342d7d31c0836175620ac9fa88e9f3a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:37:36 -0700 Subject: [PATCH 031/269] refactor(native-chat): name the structured agent list once in host types The host types, conversation commands, provider-session ownership and the runtime's create path spelled the structured agent list inline; they now use the one shared type the record and wire already validate against. --- .../structured-agent-session-host-types.ts | 3 ++- .../agent-session-wire/structured-conversation-command.ts | 3 ++- .../structured-provider-session-ownership.ts | 3 ++- ...runtime-get-structured-agent-session-create-support.ts | 8 ++++---- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 6552f8a26016..1f456038f8d7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -18,6 +18,7 @@ import type { AgentSessionAttachParams } from './structured-agent-session-attach import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' import type { AgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' export type StructuredAgentSessionCaller = { callerKey: string } @@ -28,7 +29,7 @@ export type StructuredAgentSessionCaller = { callerKey: string } export type StructuredAgentSessionReveal = { sessionId: string workspaceId: string - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider readable: boolean } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 73093da47447..a466d00d2b5e 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -22,6 +22,7 @@ import { type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { carryQueuedMessagesToClearReplacement } from './structured-agent-session-queued-mutations' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' /** A command's `error` is the sentence its row shows. */ export function conversationCommandFailure( @@ -43,7 +44,7 @@ export type ConversationReplacement = { sourceSessionId: string sessionId: string workspaceId: string - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider } const clearFingerprintOf = (sessionId: string) => diff --git a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts index d34bb5ac87fe..9a73ae2d8c0e 100644 --- a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts @@ -1,9 +1,10 @@ import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' export type StructuredProviderSessionOwnership = { sessionId: string workspaceId: string - provider: 'claude' | 'codex' + provider: AgentSessionHandleProvider providerSessionId: string lease: AgentSessionLease } diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 25a042051609..1caa929185b6 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -33,7 +33,7 @@ import type { AgentSessionHandleProvider } from '../../shared/agent-session-prov export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { async getStructuredAgentSessionCreateSupport( worktreeSelector: string, - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) return resolveStructuredAgentSessionCreateSupport({ @@ -50,7 +50,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR /** The saved selection a new chat here starts with. createSupport reports it too, so a client's * picker shows what create will run; one resolver keeps the two from drifting. */ structuredAgentSessionLaunchSeedOptions( - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider ): Record | undefined { return resolveStructuredLaunchSeedOptions( this.requireStore().getSettings().nativeChatSessionOptions, @@ -98,7 +98,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR async resolveStructuredAgentSessionCreateIntent(input: { envelope: { sessionId: string; clientOperationId: string } worktree: string - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider callerKey?: string resumeFrom?: { providerSessionId: string } }): Promise { @@ -161,7 +161,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR input: { envelope: { sessionId: string; clientOperationId: string } worktree: string - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider callerKey?: string resumeFrom?: { providerSessionId: string } }, From 88a09c4e4c6c93f3ca070fbb2da519e6f7ff7ee3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:37:25 -0700 Subject: [PATCH 032/269] fix(native-chat): narrow the record before reading its agent's option rules --- .../structured-agent-session-options-read.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 929f99845ed0..235c82f829ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -81,10 +81,10 @@ export async function recordStructuredAgentSessionOptionIntent( input: { key: string; value: string } ): Promise> { const record = store.getRecord(ctx.sessionId) - const accepted = - record !== undefined && - structuredAgentDefinition(record.provider)?.restingOptions.acceptsKey(input.key) === true - if (!record || !accepted) { + if ( + !record || + !structuredAgentDefinition(record.provider)?.restingOptions.acceptsKey(input.key) + ) { return { ok: false, refusal: refuse( From 23b927e859e93523ca523c654eefcd63f0daceee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:49:30 -0700 Subject: [PATCH 033/269] Move the turn message ordinals and the turn-row revision to the neutral timeline folder Pure moves so a shared timeline assembler can use them: Codex's message ordinal counter becomes ProviderTurnMessageOrdinals and Claude's turn-row revision becomes the provider-neutral agent-journal turn-row revision. Only names and import paths change. --- config/scripts/ci-shard-timings.json | 2 +- src/main/claude/claude-context-facts.ts | 11 +- src/main/claude/claude-open-turn.ts | 4 +- .../codex-structured-item-translation.test.ts | 28 ++--- .../codex-structured-item-translation.ts | 8 +- .../codex/codex-structured-journal-items.ts | 4 +- .../codex-structured-journal-settlement.ts | 4 +- ...-structured-journal-translation-restore.ts | 7 +- ...ctured-journal-translation-streams.test.ts | 4 +- ...ex-structured-journal-translation-turns.ts | 2 +- src/main/codex/codex-thread-item-identity.ts | 8 +- .../agent-journal-turn-row-revision.ts} | 106 +++++++++++------- .../provider-turn-message-ordinals.test.ts} | 25 +++-- .../provider-turn-message-ordinals.ts} | 19 +--- 14 files changed, 130 insertions(+), 102 deletions(-) rename src/main/{claude/claude-turn-row-revision.ts => native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts} (67%) rename src/main/{codex/codex-turn-ordinals.test.ts => native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts} (64%) rename src/main/{codex/codex-turn-ordinals.ts => native-chat/agent-session-timeline/provider-turn-message-ordinals.ts} (88%) diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 73d0226943b8..fb242a4788c8 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -1307,7 +1307,7 @@ "src/main/codex/codex-trust-grant-ledger.test.ts": 54, "src/main/codex/codex-trust-grant-main-thread-boundary.test.ts": 43, "src/main/codex/codex-trust-grant-telemetry.test.ts": 70, - "src/main/codex/codex-turn-ordinals.test.ts": 96, + "src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts": 96, "src/main/codex/codex-user-hook-trust-rebase-client.test.ts": 104, "src/main/codex/codex-user-hook-trust-moves.test.ts": 127, "src/main/codex/codex-wsl-hook-install-plan.test.ts": 51, diff --git a/src/main/claude/claude-context-facts.ts b/src/main/claude/claude-context-facts.ts index 358fd134c87a..3a1326e09363 100644 --- a/src/main/claude/claude-context-facts.ts +++ b/src/main/claude/claude-context-facts.ts @@ -25,7 +25,10 @@ import type { ClaudeOpenTurn } from './claude-open-turn' import { claudeRecord, claudeText } from './claude-structured-item-translation' import type { ClaudeTurnEnd } from './claude-turn-lifecycle-item' import { isRootClaudeFrame } from './claude-turn-opening' -import { writeClaudeTurnRow, type ClaudeTurnRowTarget } from './claude-turn-row-revision' +import { + writeAgentJournalTurnRow, + type AgentJournalTurnRowTarget +} from '../native-chat/agent-session-timeline/agent-journal-turn-row-revision' const CONVERSATION_FRAME_TYPES = new Set(['assistant', 'user', 'stream_event']) @@ -188,7 +191,7 @@ export class ClaudeContextFacts { this.windowHint = report.windowTokens const contextUsage: AgentSessionContextUsage = part === 'report' ? { used: { kind: 'report', ...report }, window } : { window } - writeClaudeTurnRow( + writeAgentJournalTurnRow( this.sink, target === null ? { newest: true } : { identity: target }, { contextUsage }, @@ -211,9 +214,9 @@ export class ClaudeContextFacts { windowIfNoneHeld?: AgentSessionContextWindow ): void { const identity = this.turn.identity - const target: ClaudeTurnRowTarget = identity ? { identity } : { newest: true } + const target: AgentJournalTurnRowTarget = identity ? { identity } : { newest: true } // A context fact often lands with no later frame to publish it, so it publishes itself. - writeClaudeTurnRow( + writeAgentJournalTurnRow( this.sink, target, { contextUsage, ...(windowIfNoneHeld ? { windowIfNoneHeld } : {}) }, diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index ddc1bd553907..4e7fe7ff9530 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -18,7 +18,7 @@ import { type ClaudeCurrentTurn, type ClaudeTurnEnd } from './claude-turn-lifecycle-item' -import { writeClaudeTurnRow } from './claude-turn-row-revision' +import { writeAgentJournalTurnRow } from '../native-chat/agent-session-timeline/agent-journal-turn-row-revision' import type { ClaudeCommandTurn } from './claude-command-turn' import { createClaudeTurnOpener, type ClaudeTurnSource } from './claude-turn-opening' @@ -203,7 +203,7 @@ export class ClaudeOpenTurn { contextUsage?: AgentSessionContextUsage ): void { const item = claudeTurnLifecycleItem(turn, end) - writeClaudeTurnRow( + writeAgentJournalTurnRow( this.deps.sink, { identity: item.identity }, { lifecycle: item.body, ...(contextUsage ? { contextUsage } : {}) }, diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index ad63d624f58b..29562552a02c 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -11,9 +11,9 @@ import { codexJournalItem, codexMessageBlocks, codexStreamingJournalItem, - CodexTurnOrdinals, - MAX_CODEX_TURN_ORDINAL_BYTES, - MAX_CODEX_TURN_ORDINAL_ENTRIES, + ProviderTurnMessageOrdinals, + MAX_PROVIDER_TURN_ORDINAL_BYTES, + MAX_PROVIDER_TURN_ORDINAL_ENTRIES, isCodexMessageItemType, readCodexThreadItem, type CodexThreadItem @@ -59,7 +59,7 @@ const RESUMED_TURN: CodexThreadItem[] = [ ] function keysFor(items: CodexThreadItem[]): string[] { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() return items .filter((item) => isCodexMessageItemType(item.type)) .map((item) => @@ -71,15 +71,15 @@ function keysFor(items: CodexThreadItem[]): string[] { describe('codex turn ordinals', () => { it('bounds forgotten turn tombstones while retaining the recent window', () => { - const ordinals = new CodexTurnOrdinals() - const total = MAX_CODEX_TURN_ORDINAL_ENTRIES + 12 + const ordinals = new ProviderTurnMessageOrdinals() + const total = MAX_PROVIDER_TURN_ORDINAL_ENTRIES + 12 for (let index = 0; index < total; index += 1) { const turnId = `turn-${index}` expect(ordinals.ordinalFor('thread-many', turnId, 'item-0')).toBe(0) ordinals.forgetTurn('thread-many', turnId) } - expect(ordinals.forgottenTurnCount).toBe(MAX_CODEX_TURN_ORDINAL_ENTRIES) + expect(ordinals.forgottenTurnCount).toBe(MAX_PROVIDER_TURN_ORDINAL_ENTRIES) // The newest completed turn still keeps its counter for a late frame. expect(ordinals.ordinalFor('thread-many', `turn-${total - 1}`, 'item-late')).toBe(1) // The oldest turn was deterministically evicted and starts a fresh key. @@ -87,7 +87,7 @@ describe('codex turn ordinals', () => { }) it('releases a forgotten turn without ever reusing an ordinal it assigned', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-1')).toBe(0) expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-2')).toBe(1) @@ -102,12 +102,12 @@ describe('codex turn ordinals', () => { }) it('bounds aggregate provider identifier bytes retained by one active turn', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() for (let index = 0; index < 3_000; index += 1) { ordinals.ordinalFor('thread', 'turn', `${index}:${'x'.repeat(512)}`) } - expect(ordinals.bytes).toBeLessThanOrEqual(MAX_CODEX_TURN_ORDINAL_BYTES) + expect(ordinals.bytes).toBeLessThanOrEqual(MAX_PROVIDER_TURN_ORDINAL_BYTES) }) }) @@ -117,7 +117,7 @@ describe('codex item identity', () => { }) it('numbers messages 0,1,2 on both sides — the projection skips the dropped command', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() const live = LIVE_TURN.map((item) => codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, item, ordinals }) ) @@ -136,7 +136,7 @@ describe('codex item identity', () => { }) it('assigns an ordinal once and reuses it, so a delta and its completion upsert one row', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0') expect(ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-1')).toBe(1) @@ -144,14 +144,14 @@ describe('codex item identity', () => { }) it('restarts numbering per turn', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0') expect(ordinals.ordinalFor(THREAD_ID, 'turn-2', 'item-1')).toBe(0) }) it('keys a non-message item and a turnless message in the orca namespace', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() const command = codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 25b821d90d9f..3f4229cb6e54 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -28,10 +28,10 @@ export { type CodexThreadItem } from './codex-thread-item-identity' export { - CodexTurnOrdinals, - MAX_CODEX_TURN_ORDINAL_BYTES, - MAX_CODEX_TURN_ORDINAL_ENTRIES -} from './codex-turn-ordinals' + ProviderTurnMessageOrdinals, + MAX_PROVIDER_TURN_ORDINAL_BYTES, + MAX_PROVIDER_TURN_ORDINAL_ENTRIES +} from '../native-chat/agent-session-timeline/provider-turn-message-ordinals' // Codex thread items → journal item bodies. diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index ec6ac67df5d4..22bb16c8ef08 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -7,7 +7,7 @@ import { requiresTerminalSettlement } from '../native-chat/agent-session-journal import { codexItemIdentity, codexJournalItem, - CodexTurnOrdinals, + ProviderTurnMessageOrdinals, readCodexThreadItem, type CodexThreadItem } from './codex-structured-item-translation' @@ -35,7 +35,7 @@ import { readCodexDispatchEcho } from './codex-structured-dispatch-echo' import type { CodexRowAttribution } from './codex-subagent-linkage' export class CodexJournalItems { - readonly ordinals = new CodexTurnOrdinals() + readonly ordinals = new ProviderTurnMessageOrdinals() readonly activeItems = new Map() readonly streams private readonly identities = new Map() diff --git a/src/main/codex/codex-structured-journal-settlement.ts b/src/main/codex/codex-structured-journal-settlement.ts index 131de9824aae..e7d0a376eca8 100644 --- a/src/main/codex/codex-structured-journal-settlement.ts +++ b/src/main/codex/codex-structured-journal-settlement.ts @@ -17,7 +17,7 @@ import { codexJournalItem, codexStreamingJournalItem, type CodexThreadItem, - type CodexTurnOrdinals + type ProviderTurnMessageOrdinals } from './codex-structured-item-translation' import type { CodexStructuredItemStreams } from './codex-structured-item-streams' import type { CodexHelperName } from './codex-collab-agent-item-translation' @@ -56,7 +56,7 @@ export function settleCodexJournalSession(input: { pendingPrompts: ReadonlyMap currentTurnIds: ReadonlyMap> primaryThreadId: string | null - ordinals: CodexTurnOrdinals + ordinals: ProviderTurnMessageOrdinals /** Terminal lifecycle for a turn the provider left running when it ended; null for a turn a * conversation command claimed, whose record the host settles. */ settledTurnLifecycle: (threadId: string, turnId: string) => AgentJournalTurnLifecycle | null diff --git a/src/main/codex/codex-structured-journal-translation-restore.ts b/src/main/codex/codex-structured-journal-translation-restore.ts index 9f7efc15ae4c..a3b4652f366e 100644 --- a/src/main/codex/codex-structured-journal-translation-restore.ts +++ b/src/main/codex/codex-structured-journal-translation-restore.ts @@ -1,5 +1,8 @@ import type { AgentJournalTurnLifecycle } from '../../shared/agent-session-journal-types' -import { readCodexThreadItem, type CodexTurnOrdinals } from './codex-structured-item-translation' +import { + readCodexThreadItem, + type ProviderTurnMessageOrdinals +} from './codex-structured-item-translation' import type { CodexJournalCompactions } from './codex-structured-journal-compactions' import type { CodexJournalItems } from './codex-structured-journal-items' import { @@ -55,7 +58,7 @@ export function restoreCodexJournalThread(input: { threadId: string thread: Record currentTurnIds: Map> - ordinals: CodexTurnOrdinals + ordinals: ProviderTurnMessageOrdinals handleItem: (event: CodexHistoryItemEvent) => CodexJournalTranslationAdmission /** Absent when the caller has no session identity to key lifecycle rows by. */ restoreTurnLifecycle?: ( diff --git a/src/main/codex/codex-structured-journal-translation-streams.test.ts b/src/main/codex/codex-structured-journal-translation-streams.test.ts index 54a2c017aa05..7bb88edc3a6f 100644 --- a/src/main/codex/codex-structured-journal-translation-streams.test.ts +++ b/src/main/codex/codex-structured-journal-translation-streams.test.ts @@ -6,7 +6,7 @@ import type { import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { projectStructuredItemsToNativeChat } from '../../shared/structured-agent-session-projection' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { CodexTurnOrdinals } from './codex-structured-item-translation' +import { ProviderTurnMessageOrdinals } from './codex-structured-item-translation' import { createCodexJournalTranslator, MAX_CODEX_GENERIC_BOOKKEEPING_ENTRIES, @@ -164,7 +164,7 @@ describe('codex journal translation', () => { }) it('releases a turn ordinal map when the turn completes', () => { - const spy = vi.spyOn(CodexTurnOrdinals.prototype, 'forgetTurn') + const spy = vi.spyOn(ProviderTurnMessageOrdinals.prototype, 'forgetTurn') try { const { translator } = translatorWith() translator.handle(TURN_STARTED) diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts index 33f138c6578d..dc955f70385c 100644 --- a/src/main/codex/codex-structured-journal-translation-turns.ts +++ b/src/main/codex/codex-structured-journal-translation-turns.ts @@ -8,7 +8,7 @@ import { } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' -import { CODEX_USER_MESSAGE_ORDINAL } from './codex-turn-ordinals' +import { CODEX_USER_MESSAGE_ORDINAL } from './codex-thread-item-identity' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission diff --git a/src/main/codex/codex-thread-item-identity.ts b/src/main/codex/codex-thread-item-identity.ts index 0488e5c00e98..663b497f7731 100644 --- a/src/main/codex/codex-thread-item-identity.ts +++ b/src/main/codex/codex-thread-item-identity.ts @@ -1,5 +1,5 @@ import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' -import type { CodexTurnOrdinals } from './codex-turn-ordinals' +import type { ProviderTurnMessageOrdinals } from '../native-chat/agent-session-timeline/provider-turn-message-ordinals' // Codex thread items → durable journal identities. // @@ -15,6 +15,10 @@ import type { CodexTurnOrdinals } from './codex-turn-ordinals' // including ones this build does not model — is skipped identically on both // sides, which is what makes the key survive a Codex release that adds one. +/** Codex records the user message first in a turn, so a restored submission is + * ordinal 0 of `(threadId, turnId)`. */ +export const CODEX_USER_MESSAGE_ORDINAL = 0 + /** Only these carry a durable `(threadId, turnId, ordinal)` identity. */ const CODEX_MESSAGE_ITEM_TYPES = new Set(['userMessage', 'agentMessage']) @@ -50,7 +54,7 @@ export function codexItemIdentity(input: { threadId: string turnId: string | null item: CodexThreadItem - ordinals: CodexTurnOrdinals + ordinals: ProviderTurnMessageOrdinals }): AgentJournalItemIdentity { const { item, turnId } = input if (turnId && isCodexMessageItemType(item.type)) { diff --git a/src/main/claude/claude-turn-row-revision.ts b/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts similarity index 67% rename from src/main/claude/claude-turn-row-revision.ts rename to src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts index 1b684ba89aaa..69356c406784 100644 --- a/src/main/claude/claude-turn-row-revision.ts +++ b/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts @@ -1,4 +1,4 @@ -// Every write to a Claude turn row is a revision of the row as the journal holds +// Every write to a turn row is a revision of the row as the journal holds // it at execution: a writer overrides only the fields it owns and keeps the // rest, whoever wrote them. Nothing about ended turns is kept in memory, so a // restart or reattach revises the same rows a live translator would. @@ -9,34 +9,37 @@ import { MAX_CONTEXT_MODEL_ID_CHARS, type AgentSessionContextUsage, type AgentSessionContextWindow -} from '../../shared/agent-session-context-usage' +} from '../../../shared/agent-session-context-usage' import { agentJournalItemKey, parseAgentJournalItemKey -} from '../../shared/agent-session-journal-item-key' +} from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemIdentity, type AgentJournalTurnItem -} from '../../shared/agent-session-journal-types' -import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' -import { estimateStructuredAgentSessionItemBytes } from '../native-chat/agent-session-wire/structured-agent-session-event-sink-estimate' +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' import type { StructuredAgentSessionEventSink, StructuredAgentSessionRevisionJournal, - StructuredAgentSessionRevisionOptions -} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' + StructuredAgentSessionRevisionOptions, + StructuredAgentSessionSinkAdmission +} from '../agent-session-wire/structured-agent-session-event-sink' /** The row a write revises: a known one, or the newest turn in the journal. */ -export type ClaudeTurnRowTarget = { identity: AgentJournalItemIdentity } | { newest: true } +export type AgentJournalTurnRowTarget = { identity: AgentJournalItemIdentity } | { newest: true } -export type ClaudeTurnRowWrite = { +export type AgentJournalTurnRowWrite = { /** The lifecycle fields this turn has now; the lifecycle writer owns all of them. */ lifecycle?: AgentJournalTurnItem /** Context parts, each replacing its namesake on the row. */ contextUsage?: AgentSessionContextUsage /** A window written only while no row in the journal holds one. */ windowIfNoneHeld?: AgentSessionContextWindow + /** Lands only while the row is absent or still running, so an ended turn is never rewritten. */ + onlyWhileRunning?: true } function jsonBytes(value: unknown): number { @@ -84,7 +87,7 @@ const TURN_ROW_BYTES_WITHOUT_CONTEXT = 8 * 1024 /** `lifecycle` over the row's current body, keeping every field it does not own. */ function reviseTurnBody( current: AgentJournalTurnItem | null, - write: ClaudeTurnRowWrite + write: AgentJournalTurnRowWrite ): AgentJournalTurnItem | null { const { lifecycle, contextUsage } = write const base = @@ -113,8 +116,8 @@ function withoutLifecycle(turn: AgentJournalTurnItem) { /** The write with its fallback window resolved against every row, since any of them may hold the newest. */ function withFallbackWindow( journal: StructuredAgentSessionRevisionJournal, - { windowIfNoneHeld, ...write }: ClaudeTurnRowWrite -): ClaudeTurnRowWrite { + { windowIfNoneHeld, ...write }: AgentJournalTurnRowWrite +): AgentJournalTurnRowWrite { if (!windowIfNoneHeld || write.contextUsage?.window) { return write } @@ -129,7 +132,7 @@ function withFallbackWindow( function findTurnRow( journal: StructuredAgentSessionRevisionJournal, - target: ClaudeTurnRowTarget + target: AgentJournalTurnRowTarget ): { itemId: string; body: AgentJournalTurnItem } | null { if ('identity' in target) { const itemId = agentJournalItemKey(target.identity) @@ -147,23 +150,60 @@ function findTurnRow( } /** How a turn-row write reaches live subscribers. */ -export type ClaudeTurnRowDelivery = { +export type AgentJournalTurnRowDelivery = { /** False only for a writer that publishes each write itself right after queueing it. */ publish: boolean options?: Omit } +/** Bytes a turn-row write may resolve to. */ +export function agentJournalTurnRowReservedBytes( + target: AgentJournalTurnRowTarget, + write: AgentJournalTurnRowWrite +): number { + const known = 'identity' in target ? target.identity : null + return ( + (known && write.lifecycle + ? estimateStructuredAgentSessionItemBytes(known, write.lifecycle) + : TURN_ROW_BYTES_WITHOUT_CONTEXT) + contextBytesBound(write.contextUsage) + ) +} + +/** The row a turn-row write lands as, read from the journal at execution; null writes nothing. */ +export function resolveAgentJournalTurnRowWrite( + journal: StructuredAgentSessionRevisionJournal, + target: AgentJournalTurnRowTarget, + write: AgentJournalTurnRowWrite, + reservedBytes: number +): { identity: AgentJournalItemIdentity; body: AgentJournalTurnItem } | null { + const known = 'identity' in target ? target.identity : null + const row = findTurnRow(journal, target) + if (write.onlyWhileRunning && row && row.body.state !== 'running') { + return null + } + const identity = known ?? (row ? parseAgentJournalItemKey(row.itemId) : null) + const body = reviseTurnBody(row?.body ?? null, withFallbackWindow(journal, write)) + if (!identity || !body) { + return null + } + if (estimateStructuredAgentSessionItemBytes(identity, body) <= reservedBytes) { + return { identity, body } + } + // Only a row grown by fields this build does not know gets here; the lifecycle still lands. + return write.lifecycle ? { identity, body: write.lifecycle } : null +} + /** - * Queue one revision of a Claude turn row. A lifecycle write creates the row + * Queue one revision of a turn row. A lifecycle write creates the row * when it is absent; a context write only ever revises one that exists. * Without a journal-reading sink only the lifecycle is written, as it was built. */ -export function writeClaudeTurnRow( +export function writeAgentJournalTurnRow( sink: StructuredAgentSessionEventSink, - target: ClaudeTurnRowTarget, - write: ClaudeTurnRowWrite, - { publish, options: delivery = {} }: ClaudeTurnRowDelivery -): void { + target: AgentJournalTurnRowTarget, + write: AgentJournalTurnRowWrite, + { publish, options: delivery = {} }: AgentJournalTurnRowDelivery +): StructuredAgentSessionSinkAdmission { // A turn record belongs to no turn. const options = { ...delivery, turnScope: AGENT_JOURNAL_THREAD_SCOPE } const revise = publish ? sink.tryReviseResolvedItemAndPublish : sink.tryReviseResolvedItem @@ -174,29 +214,13 @@ export function writeClaudeTurnRow( sink.publish() } } - return + return { accepted: true } } - const known = 'identity' in target ? target.identity : null - const reservedBytes = - (known && write.lifecycle - ? estimateStructuredAgentSessionItemBytes(known, write.lifecycle) - : TURN_ROW_BYTES_WITHOUT_CONTEXT) + contextBytesBound(write.contextUsage) - revise.call( + const reservedBytes = agentJournalTurnRowReservedBytes(target, write) + return revise.call( sink, reservedBytes, - (journal) => { - const row = findTurnRow(journal, target) - const identity = known ?? (row ? parseAgentJournalItemKey(row.itemId) : null) - const body = reviseTurnBody(row?.body ?? null, withFallbackWindow(journal, write)) - if (!identity || !body) { - return null - } - if (estimateStructuredAgentSessionItemBytes(identity, body) <= reservedBytes) { - return { identity, body } - } - // Only a row grown by fields this build does not know gets here; the lifecycle still lands. - return write.lifecycle ? { identity, body: write.lifecycle } : null - }, + (journal) => resolveAgentJournalTurnRowWrite(journal, target, write, reservedBytes), options ) } diff --git a/src/main/codex/codex-turn-ordinals.test.ts b/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts similarity index 64% rename from src/main/codex/codex-turn-ordinals.test.ts rename to src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts index 5ae12011a855..b070b449f264 100644 --- a/src/main/codex/codex-turn-ordinals.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts @@ -1,16 +1,17 @@ import { expect, it } from 'vitest' import { - CodexTurnOrdinals, - MAX_CODEX_TURN_ORDINAL_BYTES, - MAX_CODEX_TURN_ORDINAL_ENTRIES -} from './codex-turn-ordinals' + ProviderTurnMessageOrdinals, + MAX_PROVIDER_TURN_ORDINAL_BYTES, + MAX_PROVIDER_TURN_ORDINAL_ENTRIES +} from './provider-turn-message-ordinals' it('does not rescan the forgotten window for each new streamed item', () => { - const ordinals = new CodexTurnOrdinals() - for (let index = 0; index < MAX_CODEX_TURN_ORDINAL_ENTRIES; index += 1) { + const ordinals = new ProviderTurnMessageOrdinals() + for (let index = 0; index < MAX_PROVIDER_TURN_ORDINAL_ENTRIES; index += 1) { ordinals.ordinalFor('thread', String(index), 'item') ordinals.forgetTurn('thread', String(index)) } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this test counts reads of the class's private turn map, whose entries carry `active`. const turns = (ordinals as unknown as { turns: Map }).turns let reads = 0 for (const turn of turns.values()) { @@ -29,30 +30,30 @@ it('does not rescan the forgotten window for each new streamed item', () => { expect(ordinals.ordinalFor('thread', 'live', String(index))).toBe(index) } expect(reads).toBe(0) - expect(ordinals.forgottenTurnCount).toBe(MAX_CODEX_TURN_ORDINAL_ENTRIES) + expect(ordinals.forgottenTurnCount).toBe(MAX_PROVIDER_TURN_ORDINAL_ENTRIES) }) it('retains ordinal continuity on late reactivation and evicts oldest forgotten turns', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() expect(ordinals.ordinalFor('t', 'first', 'a')).toBe(0) ordinals.forgetTurn('t', 'first') expect(ordinals.ordinalFor('t', 'first', 'b')).toBe(1) expect(ordinals.forgottenTurnCount).toBe(0) ordinals.forgetTurn('t', 'first') - for (let index = 0; index < MAX_CODEX_TURN_ORDINAL_ENTRIES; index += 1) { + for (let index = 0; index < MAX_PROVIDER_TURN_ORDINAL_ENTRIES; index += 1) { ordinals.ordinalFor('t', String(index), 'a') ordinals.forgetTurn('t', String(index)) } - expect(ordinals.forgottenTurnCount).toBe(MAX_CODEX_TURN_ORDINAL_ENTRIES) + expect(ordinals.forgottenTurnCount).toBe(MAX_PROVIDER_TURN_ORDINAL_ENTRIES) expect(ordinals.ordinalFor('t', 'first', 'c')).toBe(0) }) it('keeps byte eviction bounded for active and forgotten turns', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() for (let index = 0; index < 4000; index += 1) { ordinals.ordinalFor('thread', 'live', `${index}-${'x'.repeat(240)}`) } - expect(ordinals.bytes).toBeLessThanOrEqual(MAX_CODEX_TURN_ORDINAL_BYTES) + expect(ordinals.bytes).toBeLessThanOrEqual(MAX_PROVIDER_TURN_ORDINAL_BYTES) ordinals.forgetTurn('thread', 'live') expect(ordinals.bytes).toBeLessThan(100) expect(ordinals.forgottenTurnCount).toBe(1) diff --git a/src/main/codex/codex-turn-ordinals.ts b/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.ts similarity index 88% rename from src/main/codex/codex-turn-ordinals.ts rename to src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.ts index 7e21b900d965..9405ff9281ef 100644 --- a/src/main/codex/codex-turn-ordinals.ts +++ b/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.ts @@ -1,18 +1,11 @@ -import { - boundPayload, - digestPayload -} from '../native-chat/agent-session-journal/journal-payload-bounds' - -/** Codex records the user message first in a turn, so a restored submission is - * ordinal 0 of `(threadId, turnId)`. */ -export const CODEX_USER_MESSAGE_ORDINAL = 0 +import { boundPayload, digestPayload } from '../agent-session-journal/journal-payload-bounds' /** Maximum forgotten turn keys retained for late-frame reconciliation. */ -export const MAX_CODEX_TURN_ORDINAL_ENTRIES = 256 -export const MAX_CODEX_TURN_ORDINAL_BYTES = 512 * 1024 +export const MAX_PROVIDER_TURN_ORDINAL_ENTRIES = 256 +export const MAX_PROVIDER_TURN_ORDINAL_BYTES = 512 * 1024 /** Assigns stable message ordinals while retaining a bounded late-frame window. */ -export class CodexTurnOrdinals { +export class ProviderTurnMessageOrdinals { private readonly turns = new Map< string, { assigned: Map; next: number; active: boolean } @@ -46,7 +39,7 @@ export class CodexTurnOrdinals { } private trimForgotten(): void { - while (this.forgottenTurnCount > MAX_CODEX_TURN_ORDINAL_ENTRIES) { + while (this.forgottenTurnCount > MAX_PROVIDER_TURN_ORDINAL_ENTRIES) { const oldest = this.forgottenTurns.values().next().value if (!oldest) { break @@ -67,7 +60,7 @@ export class CodexTurnOrdinals { private trimBytes(currentTurnKey: string): void { this.trimForgotten() - while (this.retainedBytes > MAX_CODEX_TURN_ORDINAL_BYTES) { + while (this.retainedBytes > MAX_PROVIDER_TURN_ORDINAL_BYTES) { const forgotten = this.forgottenTurns.values().next().value const oldest = forgotten ?? this.turns.keys().next().value if (typeof oldest !== 'string') { From 8a42c335c541df45eee0b42c7419d9d2b13680dc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:52:04 -0700 Subject: [PATCH 034/269] Admit one provider event's writes as one transition, and let rows be found again after a restart - A sink transition is admitted whole or not at all; its steps run back to back at their turn in the journal's write queue, and each resolver reads the fold with every earlier write landed. A resolver may also say where the row belongs (turn scope, provider reference), and the writer always hears how the transition landed. A resolved lifecycle batch chooses its settlement mutations from the fold at execution. - New optional row field providerItemRef: the provider's own reference for the item a row is, written only where the row's identity cannot spell it (Codex keys messages by their place in the turn and renumbers its item ids on resume). Set by the creating write, kept by revisions, indexed by the journal fold, never read by clients. A downgrade test shows an older host and client render such rows unchanged. - Provider timeline identity schemes (shared legacy arm, Codex) and the join index that resolves a provider item to its row from memory or the fold: ordinals and request incarnations are read back from the rows, so a restart or an evicted entry finds the original row instead of placing a new one. --- .../codex/codex-provider-timeline-identity.ts | 43 +++ .../journal-item-appender.ts | 9 +- .../journal-item-fold.ts | 10 +- .../journal-lifecycle-batch-appender.ts | 30 +- .../journal-provider-item-ref.test.ts | 75 ++++ .../agent-session-journal/journal-reducer.ts | 3 + .../journal-render-item.ts | 1 + .../journal-row-builders.ts | 5 +- .../journal-row-schema.ts | 12 +- .../journal-row-writer.ts | 55 +-- .../journal-store-collaborators.ts | 32 +- .../journal-store-contracts.ts | 10 +- .../agent-session-journal/journal-store.ts | 11 + .../provider-timeline-identity.ts | 133 +++++++ .../provider-timeline-joins.test.ts | 176 ++++++++++ .../provider-timeline-joins.ts | 330 ++++++++++++++++++ .../agent-session-delta-coalescer.ts | 25 ++ ...ructured-agent-session-event-sink-queue.ts | 3 + .../structured-agent-session-event-sink.ts | 9 +- ...ed-agent-session-journal-append-options.ts | 1 + ...tructured-agent-session-transition.test.ts | 265 ++++++++++++++ .../structured-agent-session-transition.ts | 156 +++++++++ src/shared/agent-session-journal-types.ts | 9 + ...n-provider-item-ref-downgrade.unit.test.ts | 127 +++++++ 24 files changed, 1486 insertions(+), 44 deletions(-) create mode 100644 src/main/codex/codex-provider-timeline-identity.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts create mode 100644 tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts diff --git a/src/main/codex/codex-provider-timeline-identity.ts b/src/main/codex/codex-provider-timeline-identity.ts new file mode 100644 index 000000000000..029c13fe56e2 --- /dev/null +++ b/src/main/codex/codex-provider-timeline-identity.ts @@ -0,0 +1,43 @@ +// Codex's persisted identities, spelled for the shared timeline assembler. +// +// The same rows Codex writes today (`codexItemIdentity`, `codexTurnLifecycleIdentity`): a user or +// assistant message inside a turn keys by (thread, turn, message ordinal), which survives a resume +// because the ordinal counts messages only; anything else keys by its Codex item id; a turn keys by +// its Codex turn id. Codex ids name one conversation for good, so the namespace spells nothing. + +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { ProviderTimelineIdentityScheme } from '../native-chat/agent-session-timeline/provider-timeline-identity' +import { + codexTurnLifecycleIdentity, + codexTurnUserItemId +} from './codex-structured-journal-translation-turns' + +export function createCodexProviderTimelineIdentityScheme(input: { + sessionId: string + primaryThreadId: () => string | null +}): ProviderTimelineIdentityScheme { + const turn: ProviderTimelineIdentityScheme['turn'] = (address) => + codexTurnLifecycleIdentity(input.sessionId, address.key.value) + return { + ordinalMessages: true, + turn, + turnId: (address) => address.key.value, + turnOpener: (address) => { + const thread = input.primaryThreadId() + return thread === null + ? agentJournalItemKey(turn(address)) + : codexTurnUserItemId(thread, address.key.value) + }, + item: (address) => { + const threadId = address.thread ?? input.primaryThreadId() ?? '' + return address.itemClass === 'message' && address.turn && address.messageOrdinal !== null + ? { + provider: 'codex', + threadId, + turnId: address.turn.value, + ordinal: address.messageOrdinal + } + : { provider: 'orca', clientMessageId: `codex-item:${threadId}:${address.key.value}` } + } + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-item-appender.ts b/src/main/native-chat/agent-session-journal/journal-item-appender.ts index 146078d830c9..479f0bc04a9d 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-appender.ts @@ -10,7 +10,12 @@ import type { JournalRow } from './journal-row-schema' const NOTHING_RESOLVED = new Error('journal_item_resolved_to_nothing') -export type JournalResolvedItem = { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } +export type JournalResolvedItem = { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + /** Replaces the call's options when only the fold could say where the row belongs. */ + options?: JournalItemAppendOptions +} export class JournalItemAppender { constructor( @@ -48,7 +53,7 @@ export class JournalItemAppender { this.deps.state, resolved.identity, resolved.body, - options + resolved.options ?? options )(seq, ts) }) .then( diff --git a/src/main/native-chat/agent-session-journal/journal-item-fold.ts b/src/main/native-chat/agent-session-journal/journal-item-fold.ts index 10aee4a99c41..aa1a02e4a70a 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-fold.ts @@ -39,6 +39,9 @@ export function upsertJournalItem( return } state.derivedTurnScope.observe(itemId, isRootAgentJournalItem(next), existing?.body, next.body) + if (next.providerItemRef !== undefined && existing?.providerItemRef === undefined) { + state.providerItemRefs.set(next.providerItemRef, itemId) + } if (!existing) { state.items.set(itemId, next) state.itemFences.set(itemId, fence) @@ -65,7 +68,9 @@ export function upsertJournalItem( sequence: existing.sequence, ...(existing.sequenceIndex !== undefined ? { sequenceIndex: existing.sequenceIndex } : {}), observedAt: existing.observedAt, - turnScope: existing.turnScope ?? next.turnScope + turnScope: existing.turnScope ?? next.turnScope, + // The creating write's, as the scope is: a revision never re-points the row at another item. + ...(existing.providerItemRef === undefined ? {} : { providerItemRef: existing.providerItemRef }) }) state.tombstones.delete(itemId) } @@ -92,6 +97,9 @@ export function removeJournalItem( ) } state.tombstones.set(itemId, revision) + if (existing?.providerItemRef !== undefined) { + state.providerItemRefs.delete(existing.providerItemRef) + } state.items.delete(itemId) state.itemFences.delete(itemId) } diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts index c05d22a87457..682f1b3c1d5f 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts @@ -1,8 +1,13 @@ import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { JournalReducerState } from './journal-reducer' +import { partitionJournalLifecycleMutations } from './journal-lifecycle-batch-partition' import { journalLifecycleBatchRowBuilder } from './journal-row-builders' -import type { JournalLifecycleBatchInput } from './journal-store-contracts' +import type { + JournalLifecycleBatchInput, + JournalResolvedLifecycleBatchInput +} from './journal-store-contracts' import type { JournalRow } from './journal-row-schema' +import type { JournalRowWriter } from './journal-row-writer' const SETTLEMENT_ALREADY_APPLIED = new Error('journal_settlement_already_applied') @@ -12,6 +17,7 @@ export class JournalLifecycleBatchAppender { state: () => JournalReducerState cursor: () => AgentJournalCursor enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise + enqueueEach: JournalRowWriter['enqueueEach'] } ) {} @@ -41,6 +47,28 @@ export class JournalLifecycleBatchAppender { }) } + /** Chooses the mutations at its turn in the queue; one too large for a row becomes consecutive + * rows that nothing else lands between, in the order resolved. */ + appendResolved(input: JournalResolvedLifecycleBatchInput): Promise { + return this.deps + .enqueueEach(() => + partitionJournalLifecycleMutations(input.settlementId, input.resolve()) + .filter((chunk) => !this.wasApplied(chunk.settlementId)) + .map((chunk) => + journalLifecycleBatchRowBuilder( + this.deps.state, + chunk.settlementId, + chunk.mutations, + input + ) + ) + ) + .then((rows) => { + const last = rows.at(-1) + return last ? { epoch: last.epoch, sequence: last.seq } : null + }) + } + private wasApplied(settlementId: string): boolean { return this.deps.state().appliedSettlementIds.has(settlementId) } diff --git a/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts b/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts new file mode 100644 index 000000000000..d80d5a539965 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts @@ -0,0 +1,75 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import { createTrackedJournalOpener } from './journal-host-database-test-support' + +const journals = createTrackedJournalOpener() +const roots: string[] = [] + +afterEach(async () => { + await journals.closeAll() + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }) + } +}) + +async function open(root?: string) { + const directory = root ?? (await mkdtemp(join(tmpdir(), 'orca-provider-item-ref-'))) + if (!root) { + roots.push(directory) + } + const journal = await journals.open({ + identity: { + sessionId: 'session-ref', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'root' } + }, + stateDirectory: directory, + now: () => 1_000 + }) + return { journal, directory } +} + +const row: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'root', + turnId: 't1', + ordinal: 0 +} +const text = (value: string) => ({ + kind: 'message' as const, + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: value }] +}) +const scope = { kind: 'turn', turnItemId: 'turn:t1' } as const + +describe('a row’s provider item reference', () => { + it('is the creating write’s, kept by revisions and indexed for its producer', async () => { + const { journal } = await open() + await journal.appendItem(row, text('a'), { fence: 1, turnScope: scope, providerItemRef: 'm0' }) + await journal.appendItem(row, text('ab'), { fence: 1, turnScope: scope, providerItemRef: 'm9' }) + await journal.appendItem(row, text('abc'), { fence: 1, turnScope: scope }) + + expect(journal.item('codex:root:t1:0')).toMatchObject({ + providerItemRef: 'm0', + body: text('abc') + }) + expect(journal.itemIdForProviderItemRef('m0')).toBe('codex:root:t1:0') + expect(journal.itemIdForProviderItemRef('m9')).toBeNull() + }) + + it('survives a reopen, and leaves the index with its row', async () => { + const { journal, directory } = await open() + await journal.appendItem(row, text('a'), { fence: 1, turnScope: scope, providerItemRef: 'm0' }) + await journals.closeAll() + + const { journal: reopened } = await open(directory) + expect(reopened.itemIdForProviderItemRef('m0')).toBe('codex:root:t1:0') + await reopened.appendTombstone(row, { fence: 1 }) + expect(reopened.itemIdForProviderItemRef('m0')).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index a7d6f150e70c..d26b52b144c4 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -56,6 +56,8 @@ export type JournalReducerState = { /** Provider item id → the submission slot that adopted it. Stops an accepted * echo from appending a second copy of the user's own message. */ aliases: Map + /** `providerItemRef` → the row that carries it, so a producer finds that row again in O(1). */ + providerItemRefs: Map appliedSettlementIds: Set /** Scope for rows stored without one; rebuilt by replay, never persisted. */ derivedTurnScope: JournalDerivedTurnScope @@ -80,6 +82,7 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou submissions: new Map(), receipts: new Map(), aliases: new Map(), + providerItemRefs: new Map(), appliedSettlementIds: new Set(), derivedTurnScope: new JournalDerivedTurnScope(), latestPersonTurnSequence: 0, diff --git a/src/main/native-chat/agent-session-journal/journal-render-item.ts b/src/main/native-chat/agent-session-journal/journal-render-item.ts index fd58b2c25e4e..76a7abf054ec 100644 --- a/src/main/native-chat/agent-session-journal/journal-render-item.ts +++ b/src/main/native-chat/agent-session-journal/journal-render-item.ts @@ -35,6 +35,7 @@ export function journalRenderItem( ...(row.recovered ? { recoveredAt: row.ts } : {}), ...(row.recovered ? { recovered: row.recovered } : {}), turnScope, + ...(row.providerItemRef === undefined ? {} : { providerItemRef: row.providerItemRef }), ...agentJournalLinkageFields(producer) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 0865df3a41fa..16bf1960e400 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -49,7 +49,8 @@ export function journalItemRowBuilder( ts: options.observedAt ?? ts, recovered: options.recovered, linkage: options, - turnScope: options.turnScope + turnScope: options.turnScope, + ...(options.providerItemRef === undefined ? {} : { providerItemRef: options.providerItemRef }) }) } @@ -249,6 +250,7 @@ export function buildJournalItemRow(input: { recovered?: true linkage?: AgentJournalProducerLinkage turnScope: AgentJournalTurnScope + providerItemRef?: string }): JournalItemRow { const itemId = agentJournalItemKey(input.identity) const resolved = input.state.aliases.get(itemId) ?? itemId @@ -268,6 +270,7 @@ export function buildJournalItemRow(input: { ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [body]), ...(input.recovered ? { recovered: input.recovered } : {}), turnScope: input.turnScope, + ...(input.providerItemRef === undefined ? {} : { providerItemRef: input.providerItemRef }), ...agentJournalLinkageFields(input.linkage) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 9276b1a8c05f..e8d1746ceb8d 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -20,6 +20,7 @@ import { type AgentJournalItemBody, type AgentJournalMessageItem, type AgentJournalProducerLinkage, + type AgentJournalProviderItemRef, type AgentJournalTurnScope, type AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' @@ -51,6 +52,9 @@ type JournalRowBase = AgentJournalProducerLinkage & { * for the reason linkage does. Absent on rows from hosts that predate it: the reducer * derives one for them on read. */ turnScope?: AgentJournalTurnScope + /** On an item row: the provider's reference for the item it creates. Rides the base for the + * reason linkage does. */ + providerItemRef?: AgentJournalProviderItemRef } /** First row of every epoch: binds the epoch to a provider handle and records why it opened. */ @@ -249,7 +253,13 @@ export function parseJournalRow(line: string): JournalRowParse { * survives is a real one: the reader scopes on PRESENCE, so `''` or a * non-string left in place would hide the row from its own author for good. */ function dropUnusableProducerLinkage(record: Record): void { - for (const field of ['agentId', 'parentAgentId', 'providerParentRef', 'producerKind']) { + for (const field of [ + 'agentId', + 'parentAgentId', + 'providerParentRef', + 'producerKind', + 'providerItemRef' + ]) { const value = record[field] if (value !== undefined && (typeof value !== 'string' || value.length === 0)) { delete record[field] diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index b07fda9459bc..17a7f551370d 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -37,27 +37,40 @@ export class JournalRowWriter { build: (seq: number, ts: number) => JournalRow, hook?: JournalRowTransactionHook ): Promise { - return this.deps.serialize(() => { - assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) - const row = build(this.deps.nextSequence(), this.deps.now()) - assertJournalFence(row.fence, this.deps.highestFence()) - try { - // One INSERT: the chat's epoch pointer moves only when the epoch does. - this.deps.database().transaction((db) => { - insertJournalRow(db, this.deps.sessionId, row) - hook?.(db, row) - this.runBookkeeping(db, row) - }) - } catch (error) { - this.deps.rolledBack?.() - throw error - } - // COMMIT landed, so the row is durable: adopt it before anything that can - // fail. Rejecting here instead would leave the next append reusing a - // sequence the table already holds. - this.deps.commit(row) - return row - }) + return this.deps.serialize(() => this.write(build, hook)) + } + + /** Rows chosen at this write's turn in the queue and written back to back, so no other write + * lands between them; each is built after the one before it is adopted. */ + enqueueEach( + resolve: () => readonly ((seq: number, ts: number) => JournalRow)[] + ): Promise { + return this.deps.serialize(() => resolve().map((build) => this.write(build))) + } + + private write( + build: (seq: number, ts: number) => JournalRow, + hook?: JournalRowTransactionHook + ): JournalRow { + assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) + const row = build(this.deps.nextSequence(), this.deps.now()) + assertJournalFence(row.fence, this.deps.highestFence()) + try { + // One INSERT: the chat's epoch pointer moves only when the epoch does. + this.deps.database().transaction((db) => { + insertJournalRow(db, this.deps.sessionId, row) + hook?.(db, row) + this.runBookkeeping(db, row) + }) + } catch (error) { + this.deps.rolledBack?.() + throw error + } + // COMMIT landed, so the row is durable: adopt it before anything that can + // fail. Rejecting here instead would leave the next append reusing a + // sequence the table already holds. + this.deps.commit(row) + return row } /** Assign the next sequence, make the row durable, and fold it through the SAME reducer diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 65ea665879b5..c00de57f6a66 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -92,6 +92,20 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal wroteBeforeOpen: (sequence) => host.journal().wroteBeforeOpen(sequence), committed: host.notifyCommitted }) + const rowWriter = new JournalRowWriter({ + sessionId: host.identity.sessionId, + now: host.now, + serialize: host.serialize, + database: host.database, + readOnly: host.readOnly, + highestFence: () => host.state().highestFence, + nextSequence: () => host.state().lastSequence + 1, + commit: host.commit, + // Every rejection is a dispatch row through this one writer; the draft + // returned-transition rides it so no path can bypass the hook. + inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row), + rolledBack: () => queuedMessages.invalidate() + }) return { epochController, queuedMessages, @@ -102,20 +116,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal restoreJournalStore(host, { epochController }).then(() => queuedMessages.repairAndPruneAtOpen() ), - rowWriter: new JournalRowWriter({ - sessionId: host.identity.sessionId, - now: host.now, - serialize: host.serialize, - database: host.database, - readOnly: host.readOnly, - highestFence: () => host.state().highestFence, - nextSequence: () => host.state().lastSequence + 1, - commit: host.commit, - // Every rejection is a dispatch row through this one writer; the draft - // returned-transition rides it so no path can bypass the hook. - inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row), - rolledBack: () => queuedMessages.invalidate() - }), + rowWriter, itemAppender: new JournalItemAppender({ state: host.state, enqueue: host.enqueue @@ -123,7 +124,8 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal lifecycleBatchAppender: new JournalLifecycleBatchAppender({ state: host.state, cursor: host.cursor, - enqueue: host.enqueue + enqueue: host.enqueue, + enqueueEach: (resolve) => rowWriter.enqueueEach(resolve) }) } } diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 85e6b99f9029..173e0c062e97 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -62,7 +62,10 @@ export type JournalItemLinkageVisitor = ( itemId: string, sequence: number, body: AgentJournalItemBody, - attribution: AgentJournalProducerLinkage & { turnScope?: AgentJournalTurnScope } + attribution: AgentJournalProducerLinkage & { + turnScope?: AgentJournalTurnScope + providerItemRef?: string + } ) => void export type JournalLifecycleBatchInput = { @@ -72,6 +75,11 @@ export type JournalLifecycleBatchInput = { recovered?: true } +export type JournalResolvedLifecycleBatchInput = Omit & { + /** Read from the fold with every earlier write landed; may return none. */ + resolve: () => readonly JournalLifecycleMutationInput[] +} + export type JournalSubmissionInput = { clientMessageId: string payloadFingerprint: string diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 5a4880155dec..53929d836e77 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -8,6 +8,7 @@ import type { AgentJournalCursor, AgentJournalItemBody, AgentJournalItemIdentity, + AgentJournalRenderItem, AgentJournalSnapshot, AgentJournalSubmission, AgentJournalThreadGoal, @@ -227,6 +228,11 @@ export class AgentSessionJournal { itemBody = (itemId: string): AgentJournalItemBody | null => this.state.items.get(itemId)?.body ?? null + /** One reduced item with its attribution, for a writer that needs the turn a row joined. */ + item = (itemId: string): AgentJournalRenderItem | null => this.state.items.get(itemId) ?? null + /** The row that carries this `providerItemRef`, if any. */ + itemIdForProviderItemRef = (ref: string) => this.state.providerItemRefs.get(ref) ?? null + /** Visits reduced items with the producer that wrote each, for a producer re-deriving what an * earlier run of this session left. */ visitItemsWithLinkage = (visit: JournalItemLinkageVisitor): void => { @@ -331,6 +337,11 @@ export class AgentSessionJournal { return this.lifecycleBatchAppender.append(input) } + /** A lifecycle batch whose mutations are chosen from the fold at its own turn in the queue; + * null when none are. */ + appendResolvedLifecycleBatch: JournalLifecycleBatchAppender['appendResolved'] = (input) => + this.lifecycleBatchAppender.appendResolved(input) + /** * Write-ahead submission row. It is durable before the caller dispatches * anything, and it doubles as the optimistic user bubble so an accepted echo diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts new file mode 100644 index 000000000000..d364b4d31acf --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts @@ -0,0 +1,133 @@ +// How the assembler's addresses become persisted journal identities. +// +// The assembler decides WHICH rows are the same row: it resolves every provider join to an +// address — the provider session's namespace, the key, the thread and turn the item belongs to, +// the item's class, and its place among its turn's messages — or mints a key when the provider +// names nothing. A scheme only spells an address as an `AgentJournalItemIdentity`. It must be pure, +// and it is the one place a provider's persisted identity shape lives, so moving a provider to a +// new identity arm is a change here. + +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentType +} from '../../../shared/agent-session-journal-types' +import { boundPayload, digestPayload } from '../agent-session-journal/journal-payload-bounds' + +/** A key the provider vouched for, or one the assembler minted because it named nothing. + * A minted value is unique per acquisition, so it never needs a namespace. */ +export type ProviderTimelineKey = { source: 'provider' | 'minted'; value: string } + +export type ProviderTimelineTurnAddress = { + /** The provider session whose ids the key belongs to. */ + namespace: string + key: ProviderTimelineKey +} + +/** Which family an item belongs to; families never share a key space. Streamed text and full + * item snapshots are one family, so a provider-named message is one row however it arrives. */ +export type ProviderTimelineItemFamily = 'item' | 'request' | 'frame' + +/** What kind of thing an item is, derived from what it carries: `message` is user or assistant + * prose; every other class is its row kind (`reasoning` text included). */ +export type ProviderTimelineItemClass = + | 'message' + | 'reasoning' + | Exclude + | 'frame' + +export type ProviderTimelineItemAddress = { + namespace: string + family: ProviderTimelineItemFamily + key: ProviderTimelineKey + /** The provider thread it came from; null when the provider named none. */ + thread: string | null + /** The provider turn it belongs to; null for a row outside any turn. */ + turn: ProviderTimelineKey | null + itemClass: ProviderTimelineItemClass + /** Its place among its (thread, turn)'s `message` items, by first write; null for any other, and + * for every item of a scheme without `ordinalMessages`. */ + messageOrdinal: number | null + /** 1 for the first request under a key; a key reused after it settled is the next one. */ + incarnation: number +} + +export type ProviderTimelineIdentityScheme = { + /** Messages inside a turn are keyed by their place among the turn's messages, not by their key. + * Such an identity must spell only (thread, turn, ordinal), and its row carries the key as + * `providerItemRef`, since nothing else could find it again. */ + ordinalMessages: boolean + turn(address: ProviderTimelineTurnAddress): AgentJournalItemIdentity + /** The id the turn row carries and a client's Stop names. */ + turnId(address: ProviderTimelineTurnAddress): string + /** The user item a turn names when no send of Orca's opened it; absent ⇒ the turn row itself. */ + turnOpener?(address: ProviderTimelineTurnAddress): string + /** With a null `messageOrdinal` it must spell only (namespace, family, key, thread, incarnation), + * so a row is found again from its key alone. */ + item(address: ProviderTimelineItemAddress): AgentJournalItemIdentity +} + +export function providerTimelineItemClass(body: AgentJournalItemBody): ProviderTimelineItemClass { + if (body.kind !== 'message') { + return body.kind + } + return body.role === 'user' || body.role === 'assistant' ? 'message' : 'reasoning' +} + +const MAX_KEY_PART_BYTES = 256 + +/** A provider id as a bounded identity part: escaped so `:` cannot forge another part, and + * digest-suffixed when long so two long ids never share a prefix-only spelling. */ +export function providerTimelineKeyPart(value: string): string { + const encoded = encodeURIComponent(value) + if (Buffer.byteLength(encoded, 'utf8') <= MAX_KEY_PART_BYTES) { + return encoded + } + const suffix = `#${digestPayload(value).slice(0, 24)}` + const head = boundPayload(encoded, { + inlineHeadBytes: MAX_KEY_PART_BYTES - Buffer.byteLength(suffix, 'utf8') + }).head + return `${head}${suffix}` +} + +/** A key as one bounded string inside its namespace (and thread, for a per-thread key). */ +export function spellProviderTimelineKey( + namespace: string, + key: ProviderTimelineKey, + thread: string | null = null +): string { + // A provider's item ids are its own per thread, so a subagent thread's ids are spelled apart. + const scope = thread === null ? '' : `${providerTimelineKeyPart(thread)}/` + return key.source === 'provider' + ? `p:${providerTimelineKeyPart(namespace)}:${scope}${providerTimelineKeyPart(key.value)}` + : `m:${providerTimelineKeyPart(key.value)}` +} + +/** + * The scheme for a provider with no identity arm of its own: the existing `legacy` arm, so no row + * shape changes. Turn rows keep the `turn-lifecycle:` record prefix the other lanes write. + * Provider keys are spelled inside their namespace, and apart from minted ones. + */ +export function createLegacyProviderTimelineIdentityScheme(input: { + agent: AgentType + sessionId: string +}): ProviderTimelineIdentityScheme { + const identity = (recordId: string): AgentJournalItemIdentity => ({ + provider: 'legacy', + agent: input.agent, + sessionId: input.sessionId, + recordId + }) + return { + ordinalMessages: false, + turn: (address) => + identity(`turn-lifecycle:${spellProviderTimelineKey(address.namespace, address.key)}`), + turnId: (address) => spellProviderTimelineKey(address.namespace, address.key), + item: (address) => + identity( + `${address.family}:${spellProviderTimelineKey(address.namespace, address.key, address.family === 'request' ? null : address.thread)}${ + address.incarnation > 1 ? `#${address.incarnation}` : '' + }` + ) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts new file mode 100644 index 000000000000..b132802369f0 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts @@ -0,0 +1,176 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalApprovalItem, + type AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import { createCodexProviderTimelineIdentityScheme } from '../../codex/codex-provider-timeline-identity' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { createLegacyProviderTimelineIdentityScheme } from './provider-timeline-identity' +import { ProviderTimelineJoins, type ProviderTimelineItemJoin } from './provider-timeline-joins' + +const journals = createTrackedJournalOpener() +const roots: string[] = [] + +afterEach(async () => { + await journals.closeAll() + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }) + } +}) + +async function openJournal(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-provider-timeline-joins-')) + roots.push(root) + return journals.open({ + identity: { + sessionId: 'session-joins', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'root' } + }, + stateDirectory: root, + now: () => 1_000 + }) +} + +const codexJoins = () => + new ProviderTimelineJoins({ + scheme: createCodexProviderTimelineIdentityScheme({ + sessionId: 'session-joins', + primaryThreadId: () => 'root' + }), + generation: 'gen-1', + namespace: 'ns' + }) + +const message = (id: string): ProviderTimelineItemJoin => ({ + family: 'item', + key: { source: 'provider', value: id }, + thread: 'root' +}) + +const inTurn = (turn: string) => ({ + thread: 'root', + turn: { source: 'provider', value: turn } as const, + scope: { kind: 'turn', turnItemId: `turn:${turn}` } as const +}) + +async function write( + journal: AgentSessionJournal, + row: { identity: AgentJournalItemIdentity; ref?: string }, + text: string +) { + await journal.appendItem( + row.identity, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] }, + { + fence: 1, + turnScope: { kind: 'turn', turnItemId: 'turn:t1' }, + ...(row.ref === undefined ? {} : { providerItemRef: row.ref }) + } + ) +} + +describe('provider timeline joins', () => { + it('finds an ordinal message again from the reference its row carries, after a restart', async () => { + const journal = await openJournal() + const before = codexJoins() + const m0 = before.place(message('m0'), 'message', inTurn('t1'), journal) + await write(journal, m0, 'first') + const m1 = before.place(message('m1'), 'message', inTurn('t1'), journal) + await write(journal, m1, 'second') + expect([m0.itemId, m1.itemId]).toEqual(['codex:root:t1:0', 'codex:root:t1:1']) + + const after = codexJoins() + expect(after.find(message('m1'), journal)).toMatchObject({ + itemId: 'codex:root:t1:1', + scope: { kind: 'turn', turnItemId: 'turn:t1' } + }) + // A new message takes the next free place, past every row the turn holds. + expect(after.place(message('m2'), 'message', inTurn('t1'), journal).itemId).toBe( + 'codex:root:t1:2' + ) + }) + + it('skips a place an echoed send holds through its submission alias', async () => { + const journal = await openJournal() + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: 'root', turnId: 't1', ordinal: 0 }, + fence: 1 + }) + expect(codexJoins().place(message('reply'), 'message', inTurn('t1'), journal).itemId).toBe( + 'codex:root:t1:1' + ) + }) + + it('finds a row whose identity spells its key without any reference', async () => { + const journal = await openJournal() + const scheme = createLegacyProviderTimelineIdentityScheme({ + agent: 'grok', + sessionId: 'session-joins' + }) + const joins = () => new ProviderTimelineJoins({ scheme, generation: 'g', namespace: 'ns' }) + const tool = joins().place(message('tool'), 'tool-call', inTurn('t1'), journal) + expect(tool.ref).toBeUndefined() + await write(journal, tool, 'x') + expect(joins().find(message('tool'), journal)?.itemId).toBe(tool.itemId) + expect(joins().find({ ...message('tool'), thread: 'other' }, journal)).toBeNull() + }) + + it('reads the current request incarnation back from the rows', async () => { + const journal = await openJournal() + const scheme = createLegacyProviderTimelineIdentityScheme({ + agent: 'grok', + sessionId: 'session-joins' + }) + const joins = () => new ProviderTimelineJoins({ scheme, generation: 'g', namespace: 'ns' }) + const key = { source: 'provider', value: 'p1' } as const + const thread = { thread: null, turn: null, scope: AGENT_JOURNAL_THREAD_SCOPE } + const approval: AgentJournalApprovalItem = { + kind: 'approval', + title: 'Run?', + detail: null, + options: [], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + const before = joins() + for (let incarnation = 1; incarnation <= 2; incarnation += 1) { + const row = before.nextRequest(key, 'approval', thread, journal) + expect(row.incarnation).toBe(incarnation) + await journal.appendItem(row.identity, approval, { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + } + expect(joins().request(key, journal)).toMatchObject({ + incarnation: 2, + itemId: agentJournalItemKey( + scheme.item({ + namespace: 'ns', + family: 'request', + key, + thread: null, + turn: null, + itemClass: 'approval', + messageOrdinal: null, + incarnation: 2 + }) + ) + }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts new file mode 100644 index 000000000000..1b85f32a0aa1 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts @@ -0,0 +1,330 @@ +// Provider joins → journal rows: the assembler's canonical join index. +// +// Every row the assembler writes is found again through here, and every entry is a cache of a +// fact the journal holds. A row whose identity spells its provider key is found by recomputing +// that identity; a row whose identity does not (a message keyed by its place among its turn's +// messages) carries the key as `providerItemRef`, which the journal indexes. So a miss — after a +// restart or an eviction — recovers the original row; it never re-places it. Message ordinals and +// request incarnations are read back from the rows that hold them. Only transition resolvers call +// the allocating members, so allocation happens in journal order and a refused event allocates +// nothing. + +import { + agentJournalItemKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalRenderItem, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { BoundedMap } from '../../../shared/bounded-map' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { + providerTimelineKeyPart, + spellProviderTimelineKey, + type ProviderTimelineIdentityScheme, + type ProviderTimelineItemAddress, + type ProviderTimelineItemClass, + type ProviderTimelineItemFamily, + type ProviderTimelineKey, + type ProviderTimelineTurnAddress +} from './provider-timeline-identity' + +const MAX_JOINED_ROWS = 1_024 +const MAX_JOINED_ROW_BYTES = 1024 * 1024 +const MAX_TURN_ORDINALS = 256 +/** Echoed sends whose slot no row holds yet; a lane records each on its send soon after. */ +const MAX_RESERVED_ECHOES = 64 + +/** One provider item as the provider names it. Item keys are the provider's per thread. */ +export type ProviderTimelineItemJoin = { + family: ProviderTimelineItemFamily + key: ProviderTimelineKey + thread: string | null +} + +export type ProviderTimelineRow = { + identity: AgentJournalItemIdentity + itemId: string + scope: AgentJournalTurnScope + /** The join's reference, on a row whose identity does not spell its key. */ + ref?: string + /** Which request under its key, 1 for the first. */ + incarnation: number +} + +export type ProviderTimelineTurnRef = { + address: ProviderTimelineTurnAddress + identity: AgentJournalItemIdentity + itemId: string + turnId: string +} + +/** Where a new row goes: the provider thread and turn, and the journal turn scope. */ +export type ProviderTimelinePlacement = { + thread: string | null + turn: ProviderTimelineKey | null + scope: AgentJournalTurnScope +} + +type Journal = StructuredAgentSessionTransitionJournal | null + +function bytes(value: string): number { + return Buffer.byteLength(value, 'utf8') +} + +export class ProviderTimelineJoins { + private readonly rows = new BoundedMap({ + maxEntries: MAX_JOINED_ROWS, + maxBytes: MAX_JOINED_ROW_BYTES, + sizeOf: (row, key) => bytes(key) + 2 * bytes(row.itemId) + 64 + }) + private readonly ordinals = new BoundedMap({ + maxEntries: MAX_TURN_ORDINALS, + maxBytes: MAX_TURN_ORDINALS * 1024, + sizeOf: (_next, place) => bytes(place) + 8 + }) + private readonly echoes = new BoundedMap({ maxEntries: MAX_RESERVED_ECHOES }) + private serial = 0 + + constructor( + private readonly deps: { + scheme: ProviderTimelineIdentityScheme + generation: string + namespace: string + } + ) {} + + get namespace(): string { + return this.deps.namespace + } + + /** A new provider session: its ids name new rows. Minted keys stay unique as they are. */ + reset(namespace: string): void { + this.deps.namespace = namespace + this.rows.clear() + this.ordinals.clear() + } + + /** A key unique to this acquisition, for something the provider names nothing. */ + mint(kind: string): ProviderTimelineKey { + this.serial += 1 + return { source: 'minted', value: `${this.deps.generation}:${kind}${this.serial}` } + } + + /** A turn's row. `namespace` defaults to the current one; a planner names the one it expects. */ + turn(key: ProviderTimelineKey, namespace = this.deps.namespace): ProviderTimelineTurnRef { + const address = { namespace, key } + const identity = this.deps.scheme.turn(address) + return { + address, + identity, + itemId: agentJournalItemKey(identity), + turnId: this.deps.scheme.turnId(address) + } + } + + /** The user item a turn names when no send of Orca's opened it. */ + turnOpener(turn: ProviderTimelineTurnRef): string { + return this.deps.scheme.turnOpener?.(turn.address) ?? turn.itemId + } + + /** The join as one bounded string: what rows carry as `providerItemRef` and caches key by. */ + reference(join: ProviderTimelineItemJoin, namespace = this.deps.namespace): string { + const thread = join.family === 'request' ? null : join.thread + return `${join.family}:${spellProviderTimelineKey(namespace, join.key, thread)}` + } + + /** The row the join already names, from memory or the fold; null when the journal holds none. */ + find( + join: ProviderTimelineItemJoin, + journal: Journal, + namespace = this.deps.namespace + ): ProviderTimelineRow | null { + const ref = this.reference(join, namespace) + const cached = this.rows.get(ref) + // A planner ahead of a reset asks about a namespace whose rows this index does not spell yet. + if (cached || !journal || namespace !== this.deps.namespace) { + return cached ?? null + } + const itemId = + journal.itemIdForProviderItemRef(ref) ?? agentJournalItemKey(this.spelled(join, 1)) + const row = rowOf(journal.item(itemId), 1) + if (row) { + this.rows.set(ref, row) + } + return row + } + + /** A new row for a join no row names yet. Allocates its message ordinal: resolvers only. */ + place( + join: ProviderTimelineItemJoin, + itemClass: ProviderTimelineItemClass, + placement: ProviderTimelinePlacement, + journal: Journal + ): ProviderTimelineRow { + const ordinal = this.ordinalFor(itemClass, placement, journal) + const identity = this.deps.scheme.item({ + ...this.address(join, 1), + turn: placement.turn, + itemClass, + messageOrdinal: ordinal + }) + const ref = this.reference(join) + const row: ProviderTimelineRow = { + identity, + itemId: agentJournalItemKey(identity), + scope: placement.scope, + ...(ordinal === null ? {} : { ref }), + incarnation: 1 + } + this.rows.set(ref, row) + return row + } + + /** An echoed send takes its message's place without a row (its send is the bubble). */ + reserveEcho( + join: ProviderTimelineItemJoin, + placement: ProviderTimelinePlacement, + journal: Journal + ) { + if (!this.deps.scheme.ordinalMessages || !placement.turn || this.find(join, journal)) { + return + } + const row = this.place(join, 'message', placement, journal) + this.echoes.set(row.itemId, true) + } + + /** The request under `key` the journal holds last: the highest incarnation with a row. */ + request( + key: ProviderTimelineKey, + journal: Journal, + namespace = this.deps.namespace + ): ProviderTimelineRow | null { + const join = { family: 'request' as const, key, thread: null } + const ref = this.reference(join, namespace) + const cached = this.rows.get(ref) + if (cached || !journal || namespace !== this.deps.namespace) { + return cached ?? null + } + let current: ProviderTimelineRow | null = null + for (let incarnation = 1; ; incarnation += 1) { + const itemId = agentJournalItemKey(this.spelled(join, incarnation)) + // A scheme that spells no incarnation names one row for every request under the key. + const row = itemId === current?.itemId ? null : rowOf(journal.item(itemId), incarnation) + if (!row) { + break + } + current = row + } + if (current) { + this.rows.set(ref, current) + } + return current + } + + /** The next request under `key`: resolvers only. */ + nextRequest( + key: ProviderTimelineKey, + itemClass: ProviderTimelineItemClass, + placement: ProviderTimelinePlacement, + journal: Journal + ): ProviderTimelineRow { + const join = { family: 'request' as const, key, thread: null } + const incarnation = (this.request(key, journal)?.incarnation ?? 0) + 1 + const identity = this.deps.scheme.item({ + ...this.address(join, incarnation), + thread: placement.thread, + turn: placement.turn, + itemClass + }) + const row = { + identity, + itemId: agentJournalItemKey(identity), + scope: placement.scope, + incarnation + } + this.rows.set(this.reference(join), row) + return row + } + + private address( + join: ProviderTimelineItemJoin, + incarnation: number + ): ProviderTimelineItemAddress { + return { + namespace: this.deps.namespace, + family: join.family, + key: join.key, + thread: join.thread, + turn: null, + itemClass: 'status', + messageOrdinal: null, + incarnation + } + } + + private spelled(join: ProviderTimelineItemJoin, incarnation: number): AgentJournalItemIdentity { + return this.deps.scheme.item(this.address(join, incarnation)) + } + + /** The next free place among the turn's messages: past every row and echoed send holding one. */ + private ordinalFor( + itemClass: ProviderTimelineItemClass, + placement: ProviderTimelinePlacement, + journal: Journal + ): number | null { + const turn = placement.turn + if (!this.deps.scheme.ordinalMessages || itemClass !== 'message' || !turn) { + return null + } + const place = `${providerTimelineKeyPart(placement.thread ?? '')}\u001f${spellProviderTimelineKey( + this.deps.namespace, + turn + )}` + let ordinal = this.ordinals.get(place) ?? 0 + while (this.taken(this.slot(placement.thread, turn, ordinal), journal)) { + ordinal += 1 + } + this.ordinals.set(place, ordinal + 1) + return ordinal + } + + private slot(thread: string | null, turn: ProviderTimelineKey, ordinal: number): string { + return agentJournalItemKey( + this.deps.scheme.item({ + ...this.address({ family: 'item', key: turn, thread }, 1), + turn, + itemClass: 'message', + messageOrdinal: ordinal + }) + ) + } + + private taken(itemId: string, journal: Journal): boolean { + return ( + this.echoes.has(itemId) || + (journal !== null && + (journal.itemBody(itemId) !== null || journal.canonicalItemId(itemId) !== itemId)) + ) + } +} + +function rowOf( + item: AgentJournalRenderItem | null, + incarnation: number +): ProviderTimelineRow | null { + const identity = item ? parseAgentJournalItemKey(item.itemId) : null + if (!item || !identity) { + return null + } + return { + identity, + itemId: item.itemId, + scope: item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE, + ...(item.providerItemRef === undefined ? {} : { ref: item.providerItemRef }), + incarnation + } +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts index b1d4c483d9bd..eff850ae8e05 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts @@ -56,6 +56,10 @@ export type AgentSessionDeltaCoalescer = { dispose: () => void /** Bounded last-known state for terminalizing a rejected completion. */ snapshot: (key: string) => AgentSessionDeltaSnapshot | null + /** Streams with text not yet emitted, in arrival order, for a caller that writes them itself. */ + dirty: () => { key: string; snapshot: AgentSessionDeltaSnapshot }[] + /** The caller wrote this stream's current text itself; nothing is owed until it grows. */ + markFlushed: (key: string) => void } function defaultSchedule(run: () => void, ms: number): () => void { @@ -215,6 +219,27 @@ export function createAgentSessionDeltaCoalescer( truncated: stream.truncated } : null + }, + dirty: () => + [...streams].flatMap(([key, stream]) => + stream.dirty + ? [ + { + key, + snapshot: { + text: stream.chunks.join(''), + observedBytes: stream.observedBytes, + truncated: stream.truncated + } + } + ] + : [] + ), + markFlushed: (key) => { + const stream = streams.get(key) + if (stream) { + stream.dirty = false + } } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index bdc07a3b3d54..bfa4b7520078 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -8,6 +8,7 @@ import type { StructuredAgentSessionSinkState, StructuredAgentSessionSinkWatermarks } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionTransitionJournal } from './structured-agent-session-transition' export type StructuredAgentSessionSinkOperation = { sequence: number @@ -85,6 +86,8 @@ export class StructuredAgentSessionSinkQueue { journalLinkage = (): StructuredAgentSessionLinkageJournal | null => this.target?.journal ?? null + journalItems = (): StructuredAgentSessionTransitionJournal | null => this.target?.journal ?? null + journalStopDecidesTurn = (turnId: string, endedAt: number, openedBy?: string): boolean => this.target?.journal.stopMarks.personStopDecides(turnId, endedAt, openedBy) ?? false diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 49572abdcc00..5cdabdce713d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -12,6 +12,10 @@ import { estimateStructuredAgentSessionItemBytes } from './structured-agent-sess import { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' import { structuredAgentSessionJournalAppendOptions } from './structured-agent-session-journal-append-options' import { createStructuredAgentSessionResolvedAppend } from './structured-agent-session-resolved-append' +import { + createStructuredAgentSessionTransitionMembers, + type StructuredAgentSessionTransitionSink +} from './structured-agent-session-transition' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionSinkAdmission = @@ -47,6 +51,8 @@ export type StructuredAgentSessionPublishOptions = Pick< /** An item write states which turn its row belongs to; the write that creates the row decides. */ export type StructuredAgentSessionItemAppendOptions = StructuredAgentSessionAppendOptions & { turnScope: AgentJournalTurnScope + /** The provider's reference for the item, on the write that creates its row. */ + providerItemRef?: string } export type StructuredAgentSessionLifecycleJournal = Pick< @@ -80,7 +86,7 @@ export type StructuredAgentSessionRevisionOptions = StructuredAgentSessionItemAp /** Compatibility alias for lifecycle callers that already use this resolver. */ export type StructuredAgentSessionLifecycleIdentityResolver = StructuredAgentSessionIdentityResolver -export type StructuredAgentSessionEventSink = { +export type StructuredAgentSessionEventSink = StructuredAgentSessionTransitionSink & { appendItem( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, @@ -286,6 +292,7 @@ export function createDeferredStructuredAgentSessionEventSink(deps: { }, tryAppendItem: appendItem, ...resolvedAppend, + ...createStructuredAgentSessionTransitionMembers(queue), journalEpoch: queue.journalEpoch, journalLinkage: queue.journalLinkage, journalStopDecidesTurn: queue.journalStopDecidesTurn, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts index e7ce667ed2b6..57bb482f7c2b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts @@ -16,6 +16,7 @@ export function structuredAgentSessionJournalAppendOptions( fence, ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }), turnScope: options.turnScope, + ...(options.providerItemRef === undefined ? {} : { providerItemRef: options.providerItemRef }), ...agentJournalLinkageFields(options) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts new file mode 100644 index 000000000000..1b242dd8d983 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -0,0 +1,265 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalToolCallItem +} from '../../../shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { createAgentSessionDeltaCoalescer } from './agent-session-delta-coalescer' +import { + createDeferredStructuredAgentSessionEventSink, + type StructuredAgentSessionSinkWatermarks +} from './structured-agent-session-event-sink' +import { testEventSinkLogging } from './structured-agent-session-logger-test-support' +import type { + StructuredAgentSessionTransition, + StructuredAgentSessionTransitionStep +} from './structured-agent-session-transition' + +const SESSION = 'session-transition' +const journals = createTrackedJournalOpener() +const roots: string[] = [] + +afterEach(async () => { + await journals.closeAll() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +function identity(recordId: string): AgentJournalItemIdentity { + return { provider: 'legacy', agent: 'grok', sessionId: SESSION, recordId } +} + +function tool(name: string, state: AgentJournalToolCallItem['state']): AgentJournalToolCallItem { + return { kind: 'tool-call', name, input: { name }, state } +} + +function itemStep( + resolve: Extract['resolve'] +): StructuredAgentSessionTransitionStep { + return { + kind: 'item', + reservedBytes: 4096, + resolve, + options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + } +} + +async function rig(watermarks: Partial = {}) { + const root = await mkdtemp(join(tmpdir(), 'orca-transition-')) + roots.push(root) + const journal: AgentSessionJournal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: { kind: 'opaque', agent: 'grok', value: 'provider-session-1' } + }, + stateDirectory: root, + now: () => 1_000 + }) + const publishes: number[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(SESSION), + watermarks + }) + const bind = () => deferred.bind({ journal, fence: 1, publish: () => publishes.push(1) }) + return { journal, deferred, sink: deferred.sink, publishes, bind } +} + +describe('structured agent-session transitions', () => { + it('lands its steps back to back, each resolved after the one before it', async () => { + const { journal, deferred, sink, publishes, bind } = await rig() + bind() + const first = identity('first') + const transition: StructuredAgentSessionTransition = { + lifecycle: false, + publish: true, + steps: [ + itemStep(() => ({ identity: first, body: tool('read', 'running') })), + // Reads the row the step before it wrote. + itemStep((view) => { + const before = view.itemBody(agentJournalItemKey(first)) + return before?.kind === 'tool-call' + ? { identity: identity('second'), body: tool(`after-${before.name}`, 'running') } + : null + }) + ] + } + expect(sink.tryAppendTransition?.(transition)).toEqual({ accepted: true }) + sink.tryAppendItem?.(identity('later'), tool('later', 'running'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await deferred.drained() + + const items = journal.snapshot().items + const start = items[0]?.sequence ?? 0 + expect(items.map((item) => [item.itemId, item.sequence - start])).toEqual([ + [agentJournalItemKey(first), 0], + [agentJournalItemKey(identity('second')), 1], + [agentJournalItemKey(identity('later')), 2] + ]) + expect(items[1]?.body).toMatchObject({ name: 'after-read' }) + expect(publishes).toHaveLength(1) + }) + + it('places a row where its resolver says, and hears every landing', async () => { + const { journal, deferred, sink, bind } = await rig() + bind() + const landed: (readonly boolean[])[] = [] + const scope = { kind: 'turn', turnItemId: 'turn-row' } as const + sink.tryAppendTransition?.({ + lifecycle: false, + publish: true, + steps: [ + itemStep(() => ({ + identity: identity('placed'), + body: tool('read', 'running'), + options: { turnScope: scope, providerItemRef: 'item:read' } + })), + itemStep(() => null) + ], + landed: (wrote) => landed.push(wrote) + }) + await deferred.drained() + + expect(journal.item(agentJournalItemKey(identity('placed')))).toMatchObject({ + turnScope: scope, + providerItemRef: 'item:read' + }) + expect(landed).toEqual([[true, false]]) + }) + + it('refuses a transition whole, so none of its steps ever lands', async () => { + const { journal, deferred, sink, bind } = await rig({ maxQueuedOperations: 1 }) + const step = (recordId: string) => + itemStep(() => ({ identity: identity(recordId), body: tool(recordId, 'running') })) + const admitted = { lifecycle: false, publish: false, steps: [step('a')] } + const refused = { lifecycle: false, publish: false, steps: [step('b'), step('c')] } + + expect(sink.tryAppendTransition?.(admitted)).toEqual({ accepted: true }) + expect(sink.tryAppendTransition?.(refused)).toEqual({ + accepted: false, + reason: 'backpressure' + }) + bind() + await deferred.drained() + + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalItemKey(identity('a')) + ]) + }) + + it('settles from the rows as they stand, in consecutive rows when one cannot hold them', async () => { + const { journal, deferred, sink, publishes, bind } = await rig() + bind() + const running = Array.from({ length: 250 }, (_, index) => identity(`tool-${index}`)) + for (const id of running) { + sink.tryAppendItem?.(id, tool('read', 'running'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + } + sink.tryAppendItem?.(identity('done'), tool('read', 'completed'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + let wrote: readonly boolean[] = [] + expect( + sink.tryAppendTransition?.({ + lifecycle: true, + publish: true, + landed: (result) => { + wrote = result + }, + steps: [ + { + kind: 'settlement', + settlementId: 'settle-all', + reservedBytes: 1, + // The settled row is a candidate too; the fold, not the caller, rules it out. + resolve: (view) => + [...running, identity('done')].flatMap((id) => { + const body = view.itemBody(agentJournalItemKey(id)) + return body?.kind === 'tool-call' && body.state === 'running' + ? [ + { + kind: 'item' as const, + identity: id, + body: { ...body, state: 'failed' as const }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + : [] + }) + } + ] + }) + ).toEqual({ accepted: true }) + sink.tryAppendItem?.(identity('after'), tool('after', 'running'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await deferred.drained() + + const items = journal.snapshot().items + const sequenceOf = (recordId: string) => + items.find((item) => item.itemId === agentJournalItemKey(identity(recordId)))?.sequence ?? 0 + expect( + items.filter((item) => item.body.kind === 'tool-call' && item.body.state === 'failed') + ).toHaveLength(250) + expect( + items.find((item) => item.itemId === agentJournalItemKey(identity('done')))?.body + ).toMatchObject({ state: 'completed' }) + // Two batch rows, back to back, between the last append before it and the first after it. + expect(sequenceOf('after') - sequenceOf('done')).toBe(3) + expect(wrote).toEqual([true]) + expect(publishes).toHaveLength(1) + }) + + it('writes and announces nothing when a step resolves to nothing', async () => { + const { journal, deferred, sink, publishes, bind } = await rig() + bind() + let wrote: readonly boolean[] = [] + sink.tryAppendTransition?.({ + lifecycle: true, + publish: true, + landed: (result) => { + wrote = result + }, + steps: [ + itemStep(() => null), + { kind: 'settlement', settlementId: 'none', reservedBytes: 1, resolve: () => [] } + ] + }) + await deferred.drained() + + expect(journal.snapshot().items).toEqual([]) + expect(wrote).toEqual([false, false]) + expect(publishes).toEqual([]) + }) +}) + +describe('coalescer text a caller writes itself', () => { + it('reports unwritten streams and owes nothing once the caller marks them written', () => { + const emitted: string[] = [] + const coalescer = createAgentSessionDeltaCoalescer({ + emit: (_key, text) => { + emitted.push(text) + }, + schedule: () => () => {} + }) + coalescer.append('a', 'Hel') + coalescer.append('b', 'Wor') + coalescer.append('a', 'lo') + + expect(coalescer.dirty().map(({ key, snapshot }) => [key, snapshot.text])).toEqual([ + ['a', 'Hello'], + ['b', 'Wor'] + ]) + coalescer.markFlushed('a') + coalescer.flushAll() + expect(emitted).toEqual(['Wor']) + expect(coalescer.dirty()).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts new file mode 100644 index 000000000000..4304338a148f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -0,0 +1,156 @@ +// One provider event's journal writes, admitted as a single sink operation. +// +// A producer that keeps state about what it wrote must change that state only for writes the sink +// took; when an event needs several rows, a refusal of the third after the first two were taken +// would leave the producer and the journal disagreeing. A transition is admitted whole or not at +// all. At execution its steps are issued in the same tick, so they sit together in the journal's +// write queue, and each step resolves against the fold with every earlier write landed — the +// steps before it included — so what a step writes is decided by the journal, not by memory. + +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' +import type { + StructuredAgentSessionItemAppendOptions, + StructuredAgentSessionSinkAdmission +} from './structured-agent-session-event-sink' +import type { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' +import { structuredAgentSessionJournalAppendOptions } from './structured-agent-session-journal-append-options' + +/** What a transition step reads: rows by key or provider reference, every row, and their turns. */ +export type StructuredAgentSessionTransitionJournal = Pick< + AgentSessionJournal, + | 'epoch' + | 'visitItems' + | 'itemBody' + | 'item' + | 'itemIdForProviderItemRef' + | 'canonicalItemId' + | 'visitItemsWithLinkage' +> + +export type StructuredAgentSessionTransitionStep = + | { + kind: 'item' + /** Bounds what `resolve` may write; a larger write fails the sink, unless `paced`. */ + reservedBytes: number + /** The size only paces the queue: the row grows by text the fold holds (a resumed message). */ + paced?: true + /** The row and its whole body; null writes nothing. Resolved `options` replace the planned + * ones, for a writer that learns the row's turn or producer only from the fold. */ + resolve: (journal: StructuredAgentSessionTransitionJournal) => { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + options?: StructuredAgentSessionItemAppendOptions + } | null + options: StructuredAgentSessionItemAppendOptions + } + | { + kind: 'settlement' + /** Unique per settlement: the journal applies one id once. */ + settlementId: string + /** Paces the queue only; the mutations are the journal's to choose. */ + reservedBytes: number + /** Read at execution; none writes nothing. */ + resolve: ( + journal: StructuredAgentSessionTransitionJournal + ) => readonly JournalLifecycleMutationInput[] + } + +export type StructuredAgentSessionTransition = { + steps: readonly StructuredAgentSessionTransitionStep[] + /** Rides the sink's lifecycle budget: it ends or settles something. */ + lifecycle: boolean + /** Announce the writes once they land, when any step wrote. */ + publish: boolean + /** Which steps wrote a row, once every step has landed or one failed (then none counts). */ + landed?: (wrote: readonly boolean[]) => void +} + +const STEP_OVERFLOW = 'structured agent-session transition step exceeded its reserved size' + +/** The sink members a transition writer uses. */ +export type StructuredAgentSessionTransitionSink = { + /** Queues one event's writes as a single admitted operation. */ + tryAppendTransition?( + transition: StructuredAgentSessionTransition + ): StructuredAgentSessionSinkAdmission + /** The bound journal's rows as they stand now; null until bound. */ + journalItems?(): StructuredAgentSessionTransitionJournal | null +} + +export function createStructuredAgentSessionTransitionMembers( + queue: StructuredAgentSessionSinkQueue +): Required { + return { tryAppendTransition: transitionAppend(queue), journalItems: queue.journalItems } +} + +function transitionAppend( + queue: StructuredAgentSessionSinkQueue +): (transition: StructuredAgentSessionTransition) => StructuredAgentSessionSinkAdmission { + return (transition) => + queue.submit({ + bytes: + transition.steps.reduce((total, step) => total + step.reservedBytes, 0) + + (transition.publish ? 1 : 0), + lifecycle: transition.lifecycle, + run: async (bound) => { + const { journal, fence } = bound + // Issued in one tick, so no write submitted after this transition lands between its steps. + const writes = transition.steps.map((step) => + step.kind === 'item' + ? journal + .appendResolvedItem( + () => { + const resolved = step.resolve(journal) + if (!resolved) { + return null + } + if ( + !step.paced && + estimateStructuredAgentSessionItemBytes(resolved.identity, resolved.body) > + step.reservedBytes + ) { + throw new Error(STEP_OVERFLOW) + } + return { + identity: resolved.identity, + body: resolved.body, + ...(resolved.options + ? { + options: structuredAgentSessionJournalAppendOptions( + fence, + resolved.options + ) + } + : {}) + } + }, + structuredAgentSessionJournalAppendOptions(fence, step.options) + ) + .then((landed) => landed !== null) + : journal + .appendResolvedLifecycleBatch({ + settlementId: step.settlementId, + resolve: () => step.resolve(journal), + fence + }) + .then((landed) => landed !== null) + ) + let wrote: boolean[] = transition.steps.map(() => false) + try { + wrote = await Promise.all(writes) + } finally { + // Heard even when a write failed, so a writer's speculation about it always ends. + transition.landed?.(wrote) + } + if (transition.publish && wrote.some(Boolean)) { + bound.publish() + } + } + }) +} diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index e5f8125a5c0f..271053b1b214 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -360,8 +360,16 @@ export const AGENT_JOURNAL_THREAD_SCOPE: AgentJournalTurnScope = { kind: 'thread /** Who produced a row and which turn it belongs to: what every item write states. */ export type AgentJournalRowAttribution = AgentJournalProducerLinkage & { turnScope: AgentJournalTurnScope + providerItemRef?: AgentJournalProviderItemRef } +/** The provider's own reference for the item a row is, as its producer joined it, on a row whose + * identity does not spell it (a Codex message keys by its turn ordinal, and Codex renumbers its + * item ids on resume). Set by the write that creates the row and kept by every revision, like the + * turn scope. Only its producer reads it back, to find the row again after a restart; no client + * depends on it. */ +export type AgentJournalProviderItemRef = string + /** Where the journal placed an item: the sequence of the row that created it, * then its place among that row's writes. The timeline's only ordering key. */ export type AgentJournalPosition = { @@ -387,6 +395,7 @@ export type AgentJournalRenderItem = AgentJournalProducerLinkage & { recoveredAt?: number /** Absent only from a host that predates it. */ turnScope?: AgentJournalTurnScope + providerItemRef?: AgentJournalProviderItemRef } // ─── Submissions ──────────────────────────────────────────────────────────── diff --git a/tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts new file mode 100644 index 000000000000..ff0ecc5fed33 --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from 'vitest' +import type { AgentJournalItemIdentity } from '../../../src/shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +// A release that predates `providerItemRef` on journal rows and render items. +const BASELINE_REF = 'v1.4.218' + +const SESSION = 'session-provider-item-ref' + +function message(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function releaseExport(module: Record, name: string): T { + const value = module[name] + if (value === undefined) { + throw new Error(`the pinned release exports no ${name}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an export the pinned release has; each caller names the shape it uses, and a changed one fails the test. + return value as T +} + +type OldRenderItem = { itemId: string; [field: string]: unknown } + +// Loads a real old build, including cold extraction and transforms. +test('an older host and client render a row carrying a provider item reference as if it had none', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-provider-item-ref-downgrade-')) + const journals = createTrackedJournalOpener() + try { + // This build: the same message twice, the first carrying the reference. + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + stateDirectory: directory, + now: () => 1_000 + }) + const body = { + kind: 'message' as const, + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: 'Hello' }] + } + const scope = { kind: 'thread' as const } + await journal.appendItem(message(0), body, { + fence: 1, + turnScope: scope, + providerItemRef: 'item:p:thread-1/item-1' + }) + await journal.appendItem(message(1), body, { fence: 1, turnScope: scope }) + const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + const rows: JournalRow[] = since.rows + const current = journal.snapshot().items + expect(current[0]?.providerItemRef).toBe('item:p:thread-1/item-1') + + const checkout = await materializeReleaseCheckout(BASELINE_REF) + const [reducer, schemas, projection] = await Promise.all( + [ + 'src/main/native-chat/agent-session-journal/journal-reducer.ts', + 'src/shared/agent-session-journal-schemas.ts', + 'src/shared/structured-agent-session-projection.ts' + ].map((path) => importReleaseCheckoutModule(checkout, path)) + ) + const createState = releaseExport<(sessionId: string, epoch: string) => unknown>( + reducer, + 'createJournalReducerState' + ) + const applyRow = releaseExport<(state: unknown, row: JournalRow) => void>( + reducer, + 'applyJournalRow' + ) + const render = releaseExport<(state: unknown) => { items: OldRenderItem[] }>( + reducer, + 'renderJournalState' + ) + const renderItemSchema = releaseExport<{ parse: (value: unknown) => OldRenderItem }>( + schemas, + 'AgentJournalRenderItemSchema' + ) + const project = releaseExport<(item: unknown) => unknown>( + projection, + 'projectStructuredItemToNativeChat' + ) + + // The older host, after a downgrade, folds the rows as it always did. + const state = createState(SESSION, journal.epoch) + for (const row of rows) { + applyRow(state, row) + } + const folded = render(state).items + expect(folded.map((item) => item.itemId)).toEqual([ + 'codex:thread-1:turn-1:0', + 'codex:thread-1:turn-1:1' + ]) + const [first, second] = folded + if (!first || !second) { + throw new Error('the older host folded both rows') + } + expect(first).not.toHaveProperty('providerItemRef') + const same = ({ itemId: _id, sequence: _seq, ...rest }: OldRenderItem) => rest + expect(same(first)).toEqual(same(second)) + + // The older client reads this host's render item without the field, and renders it the same. + const [withRef, withoutRef] = current.map((item) => renderItemSchema.parse(item)) + if (!withRef || !withoutRef) { + throw new Error('the older client read both items') + } + expect(withRef).not.toHaveProperty('providerItemRef') + expect(project(withRef)).toEqual( + project({ ...withoutRef, itemId: withRef.itemId, sequence: withRef.sequence }) + ) + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } +}) From 38ac91090884b34e4924815b7c089fc4e1039d9d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:14:23 -0700 Subject: [PATCH 035/269] refactor(native-chat): make the router's registrations the only agent definition lookup The at-rest option reads looked definitions up in a second, separately populated map, so a registered definition could route and declare capabilities while the static map decided which options a chat at rest accepted. The router now answers `definition(agent)` from its registrations, the at-rest readers take it through the host's adapter, and the built-in definitions are listed only where the runtime composes the router. --- ...-structured-effort-default-at-rest.test.ts | 7 +- .../structured-agent-definition.ts | 20 +-- ...nt-session-adapter-router-registry.test.ts | 14 +- ...ent-session-adapter-router-test-support.ts | 6 + ...structured-agent-session-adapter-router.ts | 3 + .../structured-agent-session-adapter.ts | 4 + ...-session-claude-unproven-stop-send.test.ts | 6 +- ...structured-agent-session-host-mutations.ts | 2 +- ...nt-session-operation-start-refusal.test.ts | 10 +- .../structured-agent-session-options-read.ts | 36 ++-- ...gent-session-registered-definition.test.ts | 158 ++++++++++++++++++ .../structured-agent-session-rest-test-rig.ts | 4 +- .../structured-conversation-command.test.ts | 2 + src/shared/agent-session-capabilities.ts | 5 +- 14 files changed, 225 insertions(+), 52 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 0228b1171683..61abbfbba30b 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -14,6 +14,7 @@ import { agentModelCatalogFingerprintForRecord } from '../native-chat/agent-mode import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read' +import { claudeAndCodexDefinition } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import { composeCodexSessionOptionCatalog } from '../codex/codex-structured-model-catalog' import { nativeSessionOptionsFromReport } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' import { @@ -148,7 +149,11 @@ function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { const resting = { child: null, params: { provider: 'claude' } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. const context = { - deps: { adapter: {}, store: { getRecord: () => record }, modelCatalog }, + deps: { + adapter: { definition: claudeAndCodexDefinition }, + store: { getRecord: () => record }, + modelCatalog + }, serialize: (_sessionId: string, task: () => Promise) => task(), openConversation: async () => resting, conversation: async () => resting diff --git a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts index 7578c42463c3..89614e66e6fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts @@ -1,13 +1,11 @@ // What the host knows about a structured agent before any session of it runs. // -// Each agent's own module declares its definition; shared host code reads the definition and never -// branches on the agent's name. The router routes sessions to adapters registered with these -// definitions, and at-rest reads with no adapter in hand look one up by agent here. +// Each agent's own module declares its definition, and runtime composition registers it with the +// agent's adapter. The router's registrations are the only lookup: shared host code reads a +// definition through them and never branches on the agent's name. import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' -import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' export type StructuredAgentDefinition = { /** The Orca agent whose sessions this definition describes. */ @@ -23,15 +21,3 @@ export type StructuredAgentDefinition = { effortDefaultsToModel: boolean } } - -const DEFINITIONS: ReadonlyMap = new Map( - [CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT].map((definition) => [ - definition.agent, - definition - ]) -) - -/** The definition of an agent this build drives in structured sessions; null for any other. */ -export function structuredAgentDefinition(agent: string): StructuredAgentDefinition | null { - return DEFINITIONS.get(agent) ?? null -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts index 08c77443d7ac..516296a06d5e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts @@ -10,10 +10,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' -import { - structuredAgentDefinition, - type StructuredAgentDefinition -} from './structured-agent-definition' +import type { StructuredAgentDefinition } from './structured-agent-definition' const LOCAL: AgentSessionExecutionLocation = { executionHostId: 'local', @@ -147,7 +144,7 @@ describe('StructuredAgentSessionAdapterRouter registry', () => { describe('structured agent definitions', () => { it('declares what Claude and Codex structured chats already did', () => { - expect(structuredAgentDefinition('claude')?.capabilities).toEqual({ + expect(CLAUDE_STRUCTURED_AGENT.capabilities).toEqual({ rewind: false, compact: true, threadGoal: false, @@ -156,7 +153,7 @@ describe('structured agent definitions', () => { steering: 'inject', approvalEnforcement: 'provider' }) - expect(structuredAgentDefinition('codex')?.capabilities).toEqual({ + expect(CODEX_STRUCTURED_AGENT.capabilities).toEqual({ rewind: true, compact: true, threadGoal: true, @@ -165,12 +162,11 @@ describe('structured agent definitions', () => { steering: 'inject', approvalEnforcement: 'provider' }) - expect(structuredAgentDefinition('grok')).toBeNull() }) it('keeps each agent’s resting option rules with its definition', () => { - const claude = structuredAgentDefinition('claude')!.restingOptions - const codex = structuredAgentDefinition('codex')!.restingOptions + const claude = CLAUDE_STRUCTURED_AGENT.restingOptions + const codex = CODEX_STRUCTURED_AGENT.restingOptions expect(claude.fallbackModels()?.length).toBeGreaterThan(0) expect(codex.fallbackModels()).toBeNull() expect(claude.effortDefaultsToModel).toBe(true) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts index b0e39d2c84e8..ef9e6c9f5a38 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts @@ -2,6 +2,7 @@ import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-de import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import type { StructuredAgentDefinition } from './structured-agent-definition' /** A router with this build's two agents registered under their real definitions. */ export function claudeAndCodexRouter( @@ -16,3 +17,8 @@ export function claudeAndCodexRouter( closeAdapters ) } + +/** The router's definition lookup over the same two agents, for a double that stands in for it. */ +export function claudeAndCodexDefinition(agent: string): StructuredAgentDefinition | null { + return [CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT].find((d) => d.agent === agent) ?? null +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 68720023fe65..491702059c7c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -127,6 +127,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi capabilities = (sessionId: string, agent?: string): AgentSessionCapabilities | undefined => this.capabilityOwner(sessionId, agent)?.definition.capabilities + definition = (agent: string): StructuredAgentDefinition | null => + this.registrations.get(agent)?.definition ?? null + stopBackgroundTasks: NonNullable = ( input ) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index eaa18ca334d4..9afd50481d2e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -48,6 +48,7 @@ import type { ProviderHistoryWindow } from '../agent-session-journal/journal-sub import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' +import type { StructuredAgentDefinition } from './structured-agent-definition' export class AgentSessionAcquisitionRefusal extends Error { readonly code = 'agent_session_operation_invalid' @@ -323,6 +324,9 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** What this session's agent declares it can do; `agent` answers one at rest. Answered by the * router from the agent's registered definition, never by a single adapter. */ capabilities?(sessionId: string, agent?: string): AgentSessionCapabilities | undefined + /** The definition registered for `agent`, null for an agent this runtime does not drive. Answered + * by the router from its registrations, never by a single adapter. */ + definition?(agent: string): StructuredAgentDefinition | null /** Stops exactly the tasks `taskIds` names, which the host resolves from its child records. */ stopBackgroundTasks?(input: { sessionId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index 65dcfa420738..3c2b77e41b1c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -21,6 +21,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { claudeAndCodexDefinition } from './structured-agent-session-adapter-router-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { HOST_TEST_NOW as NOW, @@ -75,7 +76,10 @@ beforeEach(async () => { store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, - adapter: Object.assign(adapter, { supportsCreate: () => true }), + adapter: Object.assign(adapter, { + supportsCreate: () => true, + definition: claudeAndCodexDefinition + }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 3fbb96ddaf87..443882c93cb1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -156,7 +156,7 @@ export async function setStructuredAgentSessionOption( }, run: (ctx) => atRest() - ? recordStructuredAgentSessionOptionIntent(context.deps.store, ctx, params) + ? recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) : plan.run(ctx) }, openForProviderWrite(context, params.envelope) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts index 47aecdaa00cc..c3e7909b8cf1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts @@ -6,6 +6,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { ensureStructuredAgentSessionAgentForOperation } from './structured-agent-session-agent-start' import { recordStructuredAgentSessionOptionIntent } from './structured-agent-session-options-read' +import { claudeAndCodexDefinition } from './structured-agent-session-adapter-router-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const SESSION = 'session-1' @@ -44,9 +45,12 @@ describe('an option picked while the chat is at rest', () => { const persistOptions = vi.fn(async () => {}) const refused = await recordStructuredAgentSessionOptionIntent( { - getRecord: () => - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the intent reads only the record's provider and options. - ({ provider: 'codex', options: {} }) as unknown as AgentSessionRecord + store: { + getRecord: () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the intent reads only the record's provider and options. + ({ provider: 'codex', options: {} }) as unknown as AgentSessionRecord + }, + adapter: { definition: claudeAndCodexDefinition } }, { sessionId: SESSION, persistOptions, publish: () => {} }, { key: 'notAnOption', value: 'x' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 235c82f829ee..e3d75139f6c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -7,7 +7,6 @@ import { refuse, - type AgentSessionModelOption, type AgentSessionOptionResult, type AgentSessionOptionsResult } from '../../../shared/agent-session-wire' @@ -15,7 +14,8 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' -import { structuredAgentDefinition } from './structured-agent-definition' +import type { StructuredAgentDefinition } from './structured-agent-definition' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { structuredAgentSessionOptionModels } from './structured-agent-session-option-models' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -23,26 +23,28 @@ import type { StructuredAgentSessionMutationContext } from './structured-agent-s type RestingOptions = Pick -/** With no catalog for the account, the list a running child of this agent falls back to. */ -function restingFallbackModels( - provider: AgentSessionRecord['provider'] -): AgentSessionModelOption[] | null { - return structuredAgentDefinition(provider)?.restingOptions.fallbackModels() ?? null +/** The at-rest rules of the record's agent, as this runtime registered it; null for any other. */ +function restingOptionRules( + adapter: Pick, + record: AgentSessionRecord +): StructuredAgentDefinition['restingOptions'] | null { + return adapter.definition?.(record.provider)?.restingOptions ?? null } async function readStructuredAgentSessionOptionsAtRest( - deps: Pick, + deps: Pick, sessionId: string ): Promise { const record = deps.store.getRecord(sessionId) if (!record) { throw new Error('agent_session_identity_required') } + const rules = restingOptionRules(deps.adapter, record) const catalog = (await deps.modelCatalog ?.read({ agent: record.provider, sessionId }) .catch(() => null)) ?? { origin: 'unknown' as const } - const listed = - catalog.origin === 'unknown' ? restingFallbackModels(record.provider) : catalog.models + // With no catalog for the account, the list a running child of this agent falls back to. + const listed = catalog.origin === 'unknown' ? (rules?.fallbackModels() ?? null) : catalog.models const models = listed ?? [] const saved = record.options ?? {} const fastMode = @@ -58,7 +60,7 @@ async function readStructuredAgentSessionOptionsAtRest( // As a live child answers: the pick, else the model's default where the agent reports that. const effort = saved.effort ?? - (structuredAgentDefinition(record.provider)?.restingOptions.effortDefaultsToModel + (rules?.effortDefaultsToModel ? models.find((entry) => entry.id === model)?.defaultEffort : undefined) return { @@ -76,15 +78,15 @@ async function readStructuredAgentSessionOptionsAtRest( /** Records a pick for the next start. Only a key the provider would accept is kept. */ export async function recordStructuredAgentSessionOptionIntent( - store: Pick, + deps: { + store: Pick + adapter: Pick + }, ctx: Pick, input: { key: string; value: string } ): Promise> { - const record = store.getRecord(ctx.sessionId) - if ( - !record || - !structuredAgentDefinition(record.provider)?.restingOptions.acceptsKey(input.key) - ) { + const record = deps.store.getRecord(ctx.sessionId) + if (!record || !restingOptionRules(deps.adapter, record)?.acceptsKey(input.key)) { return { ok: false, refusal: refuse( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts new file mode 100644 index 000000000000..74bacfe930d3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts @@ -0,0 +1,158 @@ +// The router's registrations are the only source of an agent's definition: routing, declared +// capabilities and the option rules a chat at rest reads all follow what composition registered, +// and a definition this build ships but did not register decides nothing. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' +import type { StructuredAgentDefinition } from './structured-agent-definition' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { + readStructuredAgentSessionOptions, + recordStructuredAgentSessionOptionIntent +} from './structured-agent-session-options-read' + +const RECORD: AgentSessionRecord = { + ...agentSessionRecordFixture(), + options: { model: 'pilot-model' } +} + +/** Claude, registered with rules unlike the ones Claude's own module declares. */ +const NON_DEFAULT: StructuredAgentDefinition = { + ...CLAUDE_STRUCTURED_AGENT, + capabilities: { ...CLAUDE_STRUCTURED_AGENT.capabilities, threadGoal: true }, + restingOptions: { + acceptsKey: (key) => key === 'pilotOption', + fallbackModels: () => [ + { id: 'pilot-model', label: 'Pilot', isDefault: true, defaultEffort: 'low', efforts: [] } + ], + effortDefaultsToModel: true + } +} + +function fakeAdapter(): StructuredAgentSessionAdapter { + return { + acquire: vi.fn(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 1, processStartTimeMs: 1, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: claudeProviderHandle('provider-session-1', null), + origin: 'created' as const, + mintedAtFence: fence, + observedAt: 1 + } + })), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } +} + +function pick( + adapter: StructuredAgentSessionAdapter, + record: AgentSessionRecord | null, + key: string +) { + const persistOptions = vi.fn(async () => {}) + const result = recordStructuredAgentSessionOptionIntent( + { store: { getRecord: () => record }, adapter }, + { sessionId: RECORD.sessionId, persistOptions, publish: vi.fn() }, + { key, value: 'enabled' } + ) + return { result, persistOptions } +} + +function readAtRest(adapter: StructuredAgentSessionAdapter) { + const resting = { + child: null, + params: { provider: RECORD.provider }, + journal: { threadGoal: () => null, contextUsage: () => null } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. + const context = { + deps: { adapter, store: { getRecord: () => RECORD } }, + serialize: (_sessionId: string, task: () => Promise) => task(), + openConversation: async () => resting, + conversation: async () => resting + } as unknown as StructuredAgentSessionMutationContext + return readStructuredAgentSessionOptions(context, RECORD.sessionId) +} + +describe('a registered definition', () => { + it('routes, declares capabilities and rules options at rest for its agent', async () => { + const adapter = fakeAdapter() + const router = new StructuredAgentSessionAdapterRouter( + [{ definition: NON_DEFAULT, adapter }], + async () => {} + ) + + await router.acquire({ + identity: { + sessionId: 'session-routed', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude', + providerHandle: null + }, + fence: 1, + spawnToken: 'spawn-1' + }) + expect(adapter.acquire).toHaveBeenCalledOnce() + expect(router.capabilities(RECORD.sessionId, RECORD.provider)).toBe(NON_DEFAULT.capabilities) + + const accepted = pick(router, RECORD, 'pilotOption') + await expect(accepted.result).resolves.toMatchObject({ ok: true }) + expect(accepted.persistOptions).toHaveBeenCalledWith({ + model: 'pilot-model', + pilotOption: 'enabled' + }) + // Claude's own module accepts `model`; the registration says otherwise and wins. + const refused = pick(router, RECORD, 'model') + await expect(refused.result).resolves.toMatchObject({ ok: false }) + + const options = await readAtRest(router) + expect(options.models.map((model) => model.id)).toEqual(['pilot-model']) + // No pick for effort: the registered rules read the model's default. + expect(options.current).toEqual({ model: 'pilot-model', effort: 'low' }) + expect(options.threadGoal).toEqual({ current: null }) + }) + + it('leaves an agent this runtime did not register with no rules, whatever the build ships', async () => { + const router = new StructuredAgentSessionAdapterRouter( + [{ definition: CODEX_STRUCTURED_AGENT, adapter: fakeAdapter() }], + async () => {} + ) + + expect(router.definition(RECORD.provider)).toBeNull() + expect(router.capabilities(RECORD.sessionId, RECORD.provider)).toBeUndefined() + const refused = pick(router, RECORD, 'model') + await expect(refused.result).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid', details: { reason: 'optionRejected' } } + }) + expect(refused.persistOptions).not.toHaveBeenCalled() + const options = await readAtRest(router) + expect(options.models).toEqual([]) + expect(options.current).toEqual({ model: 'pilot-model' }) + expect(options.conversationCommands).toEqual(['clear']) + }) + + it('refuses a pick for a session with no record, as before', async () => { + const router = new StructuredAgentSessionAdapterRouter( + [{ definition: CLAUDE_STRUCTURED_AGENT, adapter: fakeAdapter() }], + async () => {} + ) + const missing = pick(router, null, 'model') + await expect(missing.result).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + expect(missing.persistOptions).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index bd21fa3ff339..0cd28fafb63d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -21,6 +21,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { claudeAndCodexDefinition } from './structured-agent-session-adapter-router-test-support' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { HOST_TEST_NOW, @@ -169,7 +170,8 @@ export async function createRestTestRig( releaseAcquisition: vi.fn(async () => true), cancelTurn: async () => ({ cancelled: true }), answerPrompt: async ({ commit }) => commit(), - setOption: async () => undefined + setOption: async () => undefined, + definition: claudeAndCodexDefinition }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index b50166cd98d9..828128bc2b71 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -9,6 +9,7 @@ import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { claudeAndCodexDefinition } from './structured-agent-session-adapter-router-test-support' import { AgentSessionAcquisitionRefusal, type StructuredAgentSessionAdapter @@ -101,6 +102,7 @@ beforeEach(async () => { compact.mockReset().mockResolvedValue({ state: 'accepted', providerIdentity: null }) directory = await mkdtemp(join(tmpdir(), 'orca-conversation-command-')) adapter = { + definition: claudeAndCodexDefinition, supportsLocation: (location) => location.executionHostId === 'local' && location.wslDistro === null, acquire: vi.fn(async (input) => { diff --git a/src/shared/agent-session-capabilities.ts b/src/shared/agent-session-capabilities.ts index 64a8b0b0a322..78d63ef70d60 100644 --- a/src/shared/agent-session-capabilities.ts +++ b/src/shared/agent-session-capabilities.ts @@ -21,8 +21,9 @@ export type AgentSessionCapabilities = { steering: 'inject' | 'queue' /** * Who applies the user's permission setting. `provider`: the agent is launched with it and - * confines its own work. `orca`: the agent asks and Orca answers by the setting, so only what it - * asks about is covered, never a sandbox. + * confines its own work. `orca`: Orca answers every permission request the agent sends by the + * session's setting (a protocol-driven agent's value). The agent's own settings still decide + * WHEN it asks, so only what it asks about is covered, never a sandbox. */ approvalEnforcement: 'provider' | 'orca' } From 7ad901be3caa50af68b5c3558142d7cb45abf2fc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:51:51 -0700 Subject: [PATCH 036/269] Recover message ordinals from the journal's highest place, and forget joins read from a replaced epoch A fresh join index continued a turn's messages at the first free place, so a journal holding only a later ordinal (an imported or removed earlier row) had its sequence back-filled. The place is now one past the highest ordinal any row or echoed send holds there, read through a pure scheme reader. The join caches also drop what they read when the journal's epoch is replaced. --- .../codex/codex-provider-timeline-identity.ts | 4 + .../provider-timeline-identity.ts | 5 + .../provider-timeline-joins.test.ts | 55 ++++++++ .../provider-timeline-joins.ts | 86 +++++------- .../provider-timeline-message-places.ts | 122 ++++++++++++++++++ .../structured-agent-session-transition.ts | 4 +- 6 files changed, 220 insertions(+), 56 deletions(-) create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts diff --git a/src/main/codex/codex-provider-timeline-identity.ts b/src/main/codex/codex-provider-timeline-identity.ts index 029c13fe56e2..7da2bbd33aae 100644 --- a/src/main/codex/codex-provider-timeline-identity.ts +++ b/src/main/codex/codex-provider-timeline-identity.ts @@ -20,6 +20,10 @@ export function createCodexProviderTimelineIdentityScheme(input: { codexTurnLifecycleIdentity(input.sessionId, address.key.value) return { ordinalMessages: true, + messageSlot: (identity) => + identity.provider === 'codex' + ? { place: `${identity.threadId}\u001f${identity.turnId}`, ordinal: identity.ordinal } + : null, turn, turnId: (address) => address.key.value, turnOpener: (address) => { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts index d364b4d31acf..77286d029055 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts @@ -52,11 +52,16 @@ export type ProviderTimelineItemAddress = { incarnation: number } +export type ProviderTimelineMessageSlot = { place: string; ordinal: number } + export type ProviderTimelineIdentityScheme = { /** Messages inside a turn are keyed by their place among the turn's messages, not by their key. * Such an identity must spell only (thread, turn, ordinal), and its row carries the key as * `providerItemRef`, since nothing else could find it again. */ ordinalMessages: boolean + /** Reads an ordinal message identity back: its (thread, turn) place, spelled by the scheme, and + * its ordinal; null for any other identity. Required with `ordinalMessages`. */ + messageSlot?(identity: AgentJournalItemIdentity): ProviderTimelineMessageSlot | null turn(address: ProviderTimelineTurnAddress): AgentJournalItemIdentity /** The id the turn row carries and a client's Stop names. */ turnId(address: ProviderTimelineTurnAddress): string diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts index b132802369f0..8cddc98a2278 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts @@ -62,6 +62,13 @@ const inTurn = (turn: string) => ({ scope: { kind: 'turn', turnItemId: `turn:${turn}` } as const }) +const codexSlot = (turn: string, ordinal: number): AgentJournalItemIdentity => ({ + provider: 'codex', + threadId: 'root', + turnId: turn, + ordinal +}) + async function write( journal: AgentSessionJournal, row: { identity: AgentJournalItemIdentity; ref?: string }, @@ -118,6 +125,54 @@ describe('provider timeline joins', () => { ) }) + it('continues past the highest ordinal the journal holds, never into a gap below it', async () => { + const journal = await openJournal() + await write(journal, { identity: codexSlot('t1', 2), ref: 'item:old' }, 'old') + // Another turn and another thread keep their own places. + await write(journal, { identity: codexSlot('t2', 7) }, 'other turn') + await write(journal, { identity: { ...codexSlot('t1', 9), threadId: 'sub' } }, 'subagent') + const joins = codexJoins() + expect(joins.place(message('new'), 'message', inTurn('t1'), journal).itemId).toBe( + 'codex:root:t1:3' + ) + expect(joins.place(message('next'), 'message', inTurn('t1'), journal).itemId).toBe( + 'codex:root:t1:4' + ) + }) + + it('continues past an echoed send above the highest row', async () => { + const journal = await openJournal() + await write(journal, { identity: codexSlot('t1', 0) }, 'first') + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'accepted', + providerIdentity: codexSlot('t1', 4), + fence: 1 + }) + expect(codexJoins().place(message('reply'), 'message', inTurn('t1'), journal).itemId).toBe( + 'codex:root:t1:5' + ) + }) + + it('forgets what it read from an epoch the journal replaced', async () => { + const journal = await openJournal() + const joins = codexJoins() + const m0 = joins.place(message('m0'), 'message', inTurn('t1'), journal) + await write(journal, m0, 'first') + expect(joins.find(message('m0'), journal)?.itemId).toBe('codex:root:t1:0') + await journal.replaceEpochItems('handle_forked', 1, []) + expect(joins.find(message('m0'), journal)).toBeNull() + expect(joins.place(message('m1'), 'message', inTurn('t1'), journal).itemId).toBe( + 'codex:root:t1:0' + ) + }) + it('finds a row whose identity spells its key without any reference', async () => { const journal = await openJournal() const scheme = createLegacyProviderTimelineIdentityScheme({ diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts index 1b85f32a0aa1..7cb48073933d 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts @@ -22,7 +22,6 @@ import { import { BoundedMap } from '../../../shared/bounded-map' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { - providerTimelineKeyPart, spellProviderTimelineKey, type ProviderTimelineIdentityScheme, type ProviderTimelineItemAddress, @@ -31,12 +30,10 @@ import { type ProviderTimelineKey, type ProviderTimelineTurnAddress } from './provider-timeline-identity' +import { ProviderTimelineMessagePlaces } from './provider-timeline-message-places' const MAX_JOINED_ROWS = 1_024 const MAX_JOINED_ROW_BYTES = 1024 * 1024 -const MAX_TURN_ORDINALS = 256 -/** Echoed sends whose slot no row holds yet; a lane records each on its send soon after. */ -const MAX_RESERVED_ECHOES = 64 /** One provider item as the provider names it. Item keys are the provider's per thread. */ export type ProviderTimelineItemJoin = { @@ -81,12 +78,9 @@ export class ProviderTimelineJoins { maxBytes: MAX_JOINED_ROW_BYTES, sizeOf: (row, key) => bytes(key) + 2 * bytes(row.itemId) + 64 }) - private readonly ordinals = new BoundedMap({ - maxEntries: MAX_TURN_ORDINALS, - maxBytes: MAX_TURN_ORDINALS * 1024, - sizeOf: (_next, place) => bytes(place) + 8 - }) - private readonly echoes = new BoundedMap({ maxEntries: MAX_RESERVED_ECHOES }) + private readonly places: ProviderTimelineMessagePlaces + /** The journal epoch every cache entry was read from; a replaced epoch holds other rows. */ + private epoch: string | null = null private serial = 0 constructor( @@ -95,7 +89,12 @@ export class ProviderTimelineJoins { generation: string namespace: string } - ) {} + ) { + this.places = new ProviderTimelineMessagePlaces({ + scheme: deps.scheme, + namespace: () => this.deps.namespace + }) + } get namespace(): string { return this.deps.namespace @@ -105,7 +104,19 @@ export class ProviderTimelineJoins { reset(namespace: string): void { this.deps.namespace = namespace this.rows.clear() - this.ordinals.clear() + this.places.clear() + } + + /** Drops every cache read from an epoch the journal has since replaced (a rewind, an import). */ + private sync(journal: Journal): void { + if (!journal || journal.epoch === this.epoch) { + return + } + if (this.epoch !== null) { + this.rows.clear() + this.places.clear() + } + this.epoch = journal.epoch } /** A key unique to this acquisition, for something the provider names nothing. */ @@ -143,6 +154,7 @@ export class ProviderTimelineJoins { journal: Journal, namespace = this.deps.namespace ): ProviderTimelineRow | null { + this.sync(journal) const ref = this.reference(join, namespace) const cached = this.rows.get(ref) // A planner ahead of a reset asks about a namespace whose rows this index does not spell yet. @@ -165,7 +177,11 @@ export class ProviderTimelineJoins { placement: ProviderTimelinePlacement, journal: Journal ): ProviderTimelineRow { - const ordinal = this.ordinalFor(itemClass, placement, journal) + this.sync(journal) + const ordinal = + this.deps.scheme.ordinalMessages && itemClass === 'message' && placement.turn + ? this.places.next(placement.thread, placement.turn, journal) + : null const identity = this.deps.scheme.item({ ...this.address(join, 1), turn: placement.turn, @@ -194,7 +210,7 @@ export class ProviderTimelineJoins { return } const row = this.place(join, 'message', placement, journal) - this.echoes.set(row.itemId, true) + this.places.reserve(row.itemId) } /** The request under `key` the journal holds last: the highest incarnation with a row. */ @@ -203,6 +219,7 @@ export class ProviderTimelineJoins { journal: Journal, namespace = this.deps.namespace ): ProviderTimelineRow | null { + this.sync(journal) const join = { family: 'request' as const, key, thread: null } const ref = this.reference(join, namespace) const cached = this.rows.get(ref) @@ -269,47 +286,6 @@ export class ProviderTimelineJoins { private spelled(join: ProviderTimelineItemJoin, incarnation: number): AgentJournalItemIdentity { return this.deps.scheme.item(this.address(join, incarnation)) } - - /** The next free place among the turn's messages: past every row and echoed send holding one. */ - private ordinalFor( - itemClass: ProviderTimelineItemClass, - placement: ProviderTimelinePlacement, - journal: Journal - ): number | null { - const turn = placement.turn - if (!this.deps.scheme.ordinalMessages || itemClass !== 'message' || !turn) { - return null - } - const place = `${providerTimelineKeyPart(placement.thread ?? '')}\u001f${spellProviderTimelineKey( - this.deps.namespace, - turn - )}` - let ordinal = this.ordinals.get(place) ?? 0 - while (this.taken(this.slot(placement.thread, turn, ordinal), journal)) { - ordinal += 1 - } - this.ordinals.set(place, ordinal + 1) - return ordinal - } - - private slot(thread: string | null, turn: ProviderTimelineKey, ordinal: number): string { - return agentJournalItemKey( - this.deps.scheme.item({ - ...this.address({ family: 'item', key: turn, thread }, 1), - turn, - itemClass: 'message', - messageOrdinal: ordinal - }) - ) - } - - private taken(itemId: string, journal: Journal): boolean { - return ( - this.echoes.has(itemId) || - (journal !== null && - (journal.itemBody(itemId) !== null || journal.canonicalItemId(itemId) !== itemId)) - ) - } } function rowOf( diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts new file mode 100644 index 000000000000..e5350c39c93a --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts @@ -0,0 +1,122 @@ +// Where a new message goes among its (thread, turn)'s messages, for a scheme that keys messages +// by that place. Read back from the journal on a miss, past every place a row or an echoed send +// holds, so a restart continues the sequence the journal already has. + +import { + agentJournalItemKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import { BoundedMap } from '../../../shared/bounded-map' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { + providerTimelineKeyPart, + spellProviderTimelineKey, + type ProviderTimelineIdentityScheme, + type ProviderTimelineKey +} from './provider-timeline-identity' + +const MAX_TURN_ORDINALS = 256 +/** Echoed sends whose slot no row holds yet; a lane records each on its send soon after. */ +const MAX_RESERVED_ECHOES = 64 + +type Journal = StructuredAgentSessionTransitionJournal | null + +function bytes(value: string): number { + return Buffer.byteLength(value, 'utf8') +} + +export class ProviderTimelineMessagePlaces { + /** The next ordinal per place. */ + private readonly ordinals = new BoundedMap({ + maxEntries: MAX_TURN_ORDINALS, + maxBytes: MAX_TURN_ORDINALS * 1024, + sizeOf: (_next, place) => bytes(place) + 8 + }) + private readonly echoes = new BoundedMap({ + maxEntries: MAX_RESERVED_ECHOES, + maxBytes: MAX_RESERVED_ECHOES * 1024, + sizeOf: (_held, itemId) => bytes(itemId) + 8 + }) + + constructor( + private readonly deps: { + scheme: ProviderTimelineIdentityScheme + namespace: () => string + } + ) {} + + clear(): void { + this.ordinals.clear() + this.echoes.clear() + } + + /** Holds a place no row takes (an echoed send's). */ + reserve(itemId: string): void { + this.echoes.set(itemId, true) + } + + /** The next place in (thread, turn): allocating, so resolvers only. */ + next(thread: string | null, turn: ProviderTimelineKey, journal: Journal): number { + const place = `${providerTimelineKeyPart(thread ?? '')}\u001f${spellProviderTimelineKey( + this.deps.namespace(), + turn + )}` + let ordinal = this.ordinals.get(place) ?? this.highWater(thread, turn, journal) + // Another writer of the same turn may have taken places since this one cached its mark. + while (this.taken(agentJournalItemKey(this.slot(thread, turn, ordinal)), journal)) { + ordinal += 1 + } + this.ordinals.set(place, ordinal + 1) + return ordinal + } + + /** One past the highest ordinal the journal holds in the place, like the ordinal counter it + * continues: a gap below it (a removed or never-imported row) is never filled. */ + private highWater(thread: string | null, turn: ProviderTimelineKey, journal: Journal): number { + const read = this.deps.scheme.messageSlot + const place = read?.(this.slot(thread, turn, 0))?.place + if (!read || place === undefined || !journal) { + return 0 + } + let next = 0 + const consider = (itemId: string | null) => { + const identity = itemId === null ? null : parseAgentJournalItemKey(itemId) + const slot = identity ? read(identity) : null + if (slot?.place === place) { + next = Math.max(next, slot.ordinal + 1) + } + } + journal.visitItems((itemId) => consider(itemId)) + // An echoed send holds its place through its submission alias, with no row of its own. + for (const submission of journal.submissions()) { + consider(submission.providerItemId) + } + return next + } + + private slot( + thread: string | null, + turn: ProviderTimelineKey, + ordinal: number + ): AgentJournalItemIdentity { + return this.deps.scheme.item({ + namespace: this.deps.namespace(), + family: 'item', + key: turn, + thread, + turn, + itemClass: 'message', + messageOrdinal: ordinal, + incarnation: 1 + }) + } + + private taken(itemId: string, journal: Journal): boolean { + return ( + this.echoes.has(itemId) || + (journal !== null && + (journal.itemBody(itemId) !== null || journal.canonicalItemId(itemId) !== itemId)) + ) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts index 4304338a148f..c67dab93edef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -21,10 +21,12 @@ import type { import type { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' import { structuredAgentSessionJournalAppendOptions } from './structured-agent-session-journal-append-options' -/** What a transition step reads: rows by key or provider reference, every row, and their turns. */ +/** What a transition step reads: rows by key or provider reference, every row, their turns, and + * the sends whose echo holds a provider item's place. */ export type StructuredAgentSessionTransitionJournal = Pick< AgentSessionJournal, | 'epoch' + | 'submissions' | 'visitItems' | 'itemBody' | 'item' From eba54cdc5d431043db301c8b6166cb4cc11f4b26 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:57:06 -0700 Subject: [PATCH 037/269] feat(native-chat): open the structured-chat wire and stored records to registered agents A host's structured agents are the ones its runtime registered. Records, RPC params, persisted tabs and the model catalog accept any registered agent instead of naming Claude and Codex; each agent's definition declares the transport its handles live in and the variable its account home pins. A new runtime capability, agent-session.structured.registered-agents.v1, advertises that a host accepts and lists its agents (agentSession.agents, with each agent's capability record), and the host withholds any other agent's tabs and restart offers from clients that do not advertise it. --- .../claude-structured-acquire-catalog.ts | 11 ++ .../claude-structured-agent-definition.ts | 3 + .../claude-structured-launch-resolution.ts | 4 +- .../claude-structured-session-acquisition.ts | 8 +- .../codex/codex-structured-acquire-catalog.ts | 3 +- .../codex-structured-agent-definition.ts | 3 + .../codex-structured-launch-resolution.ts | 4 +- .../agent-model-catalog-fingerprint.ts | 11 +- .../agent-model-catalog-persistence.ts | 3 +- .../agent-model-catalog-service.ts | 11 +- .../agent-model-catalog-store.ts | 6 +- .../agent-project-model-override.ts | 2 +- .../structured-agent-definition.ts | 6 +- ...structured-agent-session-adapter-router.ts | 3 + .../structured-agent-session-adapter.ts | 2 + .../structured-agent-session-attach.ts | 6 +- .../structured-agent-session-host-types.ts | 4 +- .../structured-agent-session-host.ts | 6 + ...ructured-agent-session-send-preparation.ts | 5 +- ...uctured-agent-session-status-child-work.ts | 4 +- .../structured-conversation-command.ts | 4 +- .../structured-provider-session-ownership.ts | 4 +- ...structured-agent-session-create-support.ts | 9 +- .../agent-session-legacy-record-import.ts | 18 +- src/main/runtime/agent-session-record-rows.ts | 15 +- .../agent-session-record-store-file.ts | 21 ++- ...agent-session-record-store-test-harness.ts | 7 +- .../runtime/agent-session-record-store.ts | 9 +- .../agent-session-reservation-admission.ts | 4 +- src/main/runtime/agent-session-store-draft.ts | 6 +- .../runtime/agent-session-store-row-rules.ts | 8 +- .../agent-session-store-transactions.ts | 6 +- ...structured-agent-session-create-support.ts | 170 +++++++++++------- ...tore-structured-agent-session-tabs-once.ts | 8 +- src/main/runtime/rpc/methods/index.ts | 2 + .../session-tab-agent-status-projection.ts | 30 ++-- .../structured-agent-session-agents.ts | 23 +++ .../structured-agent-session-create.ts | 13 +- .../structured-agent-session-policy.ts | 17 ++ ...structured-agent-session-restart-resume.ts | 15 +- .../structured-agent-session-schemas.ts | 1 + .../rpc/methods/structured-agent-session.ts | 6 +- .../structured-agent-session-journal-open.ts | 6 +- .../structured-agent-session-runtime.ts | 15 +- .../structured-session-worktree-teardown.ts | 3 +- .../runtime/structured-worker-authority.ts | 7 +- src/shared/agent-session-account-home.ts | 31 +--- .../agent-session-provider-handle-encoding.ts | 34 ++-- src/shared/agent-session-provider-handle.ts | 13 +- src/shared/agent-session-record.ts | 35 ++-- src/shared/agent-session-registered-agents.ts | 73 ++++++++ ...agent-session-stored-agent.test-fixture.ts | 19 ++ src/shared/agent-session-stored-agent.ts | 50 ++++++ src/shared/protocol-version.ts | 9 + .../rpc-params-catalog.generated.ts | 2 + .../structured-agent-session-params.ts | 16 +- .../runtime-mobile-session-tab-contracts.ts | 5 +- ...uctured-agent-session-child-work-legacy.ts | 8 +- .../structured-native-chat-launch-route.ts | 16 +- src/shared/workspace-session-schema.ts | 10 +- 60 files changed, 618 insertions(+), 235 deletions(-) create mode 100644 src/main/claude/claude-structured-acquire-catalog.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-agents.ts create mode 100644 src/shared/agent-session-registered-agents.ts create mode 100644 src/shared/agent-session-stored-agent.test-fixture.ts create mode 100644 src/shared/agent-session-stored-agent.ts diff --git a/src/main/claude/claude-structured-acquire-catalog.ts b/src/main/claude/claude-structured-acquire-catalog.ts new file mode 100644 index 000000000000..e4144a22c014 --- /dev/null +++ b/src/main/claude/claude-structured-acquire-catalog.ts @@ -0,0 +1,11 @@ +import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import type { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { CLAUDE_STRUCTURED_AGENT } from './claude-structured-agent-definition' + +/** A Claude session's catalog, keyed by the config directory it launched under. */ +export function claudeAcquireCatalogAccess( + store: AgentModelCatalogStore | undefined, + claudeConfigDir: string | null +) { + return agentModelCatalogSessionAccess(store, CLAUDE_STRUCTURED_AGENT, claudeConfigDir) +} diff --git a/src/main/claude/claude-structured-agent-definition.ts b/src/main/claude/claude-structured-agent-definition.ts index ca37f7abfad3..991d681c988c 100644 --- a/src/main/claude/claude-structured-agent-definition.ts +++ b/src/main/claude/claude-structured-agent-definition.ts @@ -1,9 +1,12 @@ import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import { CLAUDE_STRUCTURED_HANDLE_NAMESPACE } from '../../shared/agent-session-provider-handle-encoding' import { isClaudeStructuredOptionKey } from './claude-structured-options' import { claudeFallbackModelOptions } from './claude-structured-session-options' export const CLAUDE_STRUCTURED_AGENT: StructuredAgentDefinition = { agent: 'claude', + handleTransport: CLAUDE_STRUCTURED_HANDLE_NAMESPACE.transport, + accountHomeVariable: 'CLAUDE_CONFIG_DIR', capabilities: { // Orca's marker-based rewind proof can never pass on the real binary; rewind returns via a fork. rewind: false, diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 95e43e2a276b..6fea15d79fb7 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -34,7 +34,7 @@ import { resolveClaudeCommand } from '../codex-cli/command' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' -import { agentConfigDirectoryVariable } from '../../shared/agent-session-account-home' +import { CLAUDE_STRUCTURED_AGENT } from './claude-structured-agent-definition' export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const export const CLAUDE_SESSION_STATE_EVENTS_ENV = 'CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS' @@ -251,7 +251,7 @@ export function createClaudeStructuredLaunchResolver( `claude structured sessions run on the local host, not ${record.location.executionHostId}` ) } - const pinned = agentConfigDirectoryVariable('claude') + const pinned = CLAUDE_STRUCTURED_AGENT.accountHomeVariable if (record.accountHome.variable !== pinned) { throw new Error(`claude sessions pin ${pinned}, not ${record.accountHome.variable}`) } diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 404787620e95..74c14f84301e 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -41,7 +41,7 @@ import { readClaudeTranscriptEntryUuid } from './claude-transcript-entry-uuid' import { persistClaudeTurnResumePoint } from './claude-structured-resume-point' import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' import { resolveClaudeAcquisitionLaunch } from './claude-structured-acquisition-launch' -import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import { claudeAcquireCatalogAccess } from './claude-structured-acquire-catalog' import { bindClaudeConnectionJournalControls, createClaudeJournalFailureHandler @@ -257,11 +257,7 @@ export async function acquireClaudeSession({ }) const session = publication.session liveSession = session - const catalogAccess = agentModelCatalogSessionAccess( - deps.modelCatalog, - 'claude', - launch.claudeConfigDir - ) + const catalogAccess = claudeAcquireCatalogAccess(deps.modelCatalog, launch.claudeConfigDir) if (catalogAccess) { session.catalogAccess = catalogAccess } diff --git a/src/main/codex/codex-structured-acquire-catalog.ts b/src/main/codex/codex-structured-acquire-catalog.ts index 6a420f2dd990..0e66d5c4ae02 100644 --- a/src/main/codex/codex-structured-acquire-catalog.ts +++ b/src/main/codex/codex-structured-acquire-catalog.ts @@ -11,12 +11,13 @@ import { type CodexSessionOptionCatalog } from './codex-structured-model-catalog' import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' export function codexAcquireCatalogAccess( deps: Pick, launch: Pick ): CodexSessionCatalogAccess | undefined { - return agentModelCatalogSessionAccess(deps.modelCatalog, 'codex', launch.codexHome) + return agentModelCatalogSessionAccess(deps.modelCatalog, CODEX_STRUCTURED_AGENT, launch.codexHome) } /** The one catalog read a fast-mode restore needs, store-first. Null degrades diff --git a/src/main/codex/codex-structured-agent-definition.ts b/src/main/codex/codex-structured-agent-definition.ts index 8aab5137b114..317acdbd3a35 100644 --- a/src/main/codex/codex-structured-agent-definition.ts +++ b/src/main/codex/codex-structured-agent-definition.ts @@ -1,8 +1,11 @@ import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import { CODEX_STRUCTURED_HANDLE_NAMESPACE } from '../../shared/agent-session-provider-handle-encoding' import { isCodexTurnOptionKey } from './codex-structured-turn-start' export const CODEX_STRUCTURED_AGENT: StructuredAgentDefinition = { agent: 'codex', + handleTransport: CODEX_STRUCTURED_HANDLE_NAMESPACE.transport, + accountHomeVariable: 'CODEX_HOME', capabilities: { // A thread with legacy, unpaginated history narrows this to unsupported once it runs. rewind: true, diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index b48cc4cae113..9802b18286df 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -15,7 +15,7 @@ import type { CodexStructuredLaunch } from './codex-structured-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof' import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' -import { agentConfigDirectoryVariable } from '../../shared/agent-session-account-home' +import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' export type CodexStructuredLaunchResolverDeps = { store: AgentSessionRecordStore @@ -86,7 +86,7 @@ export function createCodexStructuredLaunchResolver( ) { throw new Error('codex structured sessions require Windows process creation-time proof') } - const pinned = agentConfigDirectoryVariable('codex') + const pinned = CODEX_STRUCTURED_AGENT.accountHomeVariable if (accountHome.variable !== pinned) { throw new Error(`codex sessions pin ${pinned}, not ${accountHome.variable}`) } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts index 70c59acd77b4..d59f0d124c33 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts @@ -4,8 +4,7 @@ import type { AgentModelCatalogSessionAccess, AgentModelCatalogStore } from './agent-model-catalog-store' -import { agentConfigDirectoryVariable } from '../../../shared/agent-session-account-home' -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { AgentSessionStoredAgent } from '../../../shared/agent-session-stored-agent' /** * Everything that changes which models a listing can answer with: the agent, @@ -14,7 +13,7 @@ import type { AgentSessionHandleProvider } from '../../../shared/agent-session-p * key is corrected by the next refresh, never by the fingerprint. */ export type AgentModelCatalogIdentity = { - agent: 'claude' | 'codex' + agent: string accountHomeVariable: string accountHomePath: string /** Null on the native host; WSL distros each carry their own CLI. */ @@ -57,7 +56,7 @@ export function agentModelCatalogFingerprintForRecord( * Native only: both structured adapters refuse non-native locations at launch. */ export function agentModelCatalogSessionAccess( store: AgentModelCatalogStore | undefined, - agent: AgentSessionHandleProvider, + agent: Pick, accountHomePath: string | null ): AgentModelCatalogSessionAccess | undefined { if (!store || !accountHomePath) { @@ -66,8 +65,8 @@ export function agentModelCatalogSessionAccess( return { store, fingerprint: agentModelCatalogFingerprint({ - agent, - accountHomeVariable: agentConfigDirectoryVariable(agent), + agent: agent.agent, + accountHomeVariable: agent.accountHomeVariable, accountHomePath, wslDistro: null }), diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts index 034a6fc8b4aa..d47e9efbec56 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts @@ -5,6 +5,7 @@ import type { AgentSessionOptionChoice } from '../../../shared/agent-session-wire' import type { AgentModelCatalogEntry } from './agent-model-catalog-store' +import { isStructuredAgentId } from '../../../shared/agent-session-provider-handle-encoding' const SCHEMA_VERSION = 1 const SAVE_COALESCE_MS = 500 @@ -67,7 +68,7 @@ function parseEntry(value: unknown): AgentModelCatalogEntry | null { const row = asRecord(value) if ( !row || - (row.agent !== 'claude' && row.agent !== 'codex') || + !isStructuredAgentId(row.agent) || typeof row.fingerprint !== 'string' || (row.origin !== 'live-session' && row.origin !== 'probe') || typeof row.fetchedAt !== 'number' || diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index a8cc70ade7c9..24bd41df0fcf 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -3,7 +3,6 @@ import type { AgentSessionAccountHome, AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import { agentModelCatalogFingerprint, agentModelCatalogFingerprintForRecord @@ -20,12 +19,12 @@ export type AgentModelCatalogServiceDeps = { /** The account home a structured launch for this agent would pin right now — * the SAME resolver the create path fills `record.accountHome` with, so a * record-less read can never answer from another account's listing. */ - resolveAccountHome: (agent: AgentSessionHandleProvider) => Promise + resolveAccountHome: (agent: string) => Promise /** Session-less listers, one per agent that has one on this host. */ - probes?: Partial> + probes?: Readonly>> /** Whether the workspace's own config could pick a model other than the listed default. */ workspaceMayOverrideDefaultModel?: (input: { - agent: 'claude' | 'codex' + agent: string workspacePath: string accountHomePath: string }) => Promise @@ -33,7 +32,7 @@ export type AgentModelCatalogServiceDeps = { export type AgentModelCatalogService = { read: (params: { - agent: 'claude' | 'codex' + agent: string sessionId?: string /** Where a new chat would run; null when one was named but is not a local directory. */ workspacePath?: string | null @@ -58,7 +57,7 @@ function resultFromEntry( /** A named workspace keeps the listed default only when none of its own config can replace it. */ async function workspaceKeepsListedDefault( deps: AgentModelCatalogServiceDeps, - agent: 'claude' | 'codex', + agent: string, workspacePath: string | null | undefined, accountHomePath: string | null ): Promise { diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts index dcb6e3935ac5..bb6055e4e39f 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts @@ -19,7 +19,7 @@ export const AGENT_MODEL_CATALOG_VALIDATION_MIN_AGE_MS = 60_000 export const AGENT_MODEL_CATALOG_MAX_ENTRIES = 256 export type AgentModelCatalogEntry = { - agent: 'claude' | 'codex' + agent: string fingerprint: string models: AgentSessionModelOption[] fastModeSupport?: AgentSessionFastModeSupport @@ -144,7 +144,7 @@ export class AgentModelCatalogStore { recordSuccess( fingerprint: string, - agent: 'claude' | 'codex', + agent: string, success: AgentModelCatalogSuccess ): AgentModelCatalogEntry | null { if (success.models.length === 0) { @@ -180,7 +180,7 @@ export class AgentModelCatalogStore { * Resolves with the entry on success and null on failure — never rejects. */ refresh( fingerprint: string, - agent: 'claude' | 'codex', + agent: string, listModels: () => Promise ): Promise { const inFlight = this.refreshes.get(fingerprint) diff --git a/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts b/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts index a6e51490a3ea..b9fefa7e19d0 100644 --- a/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts +++ b/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts @@ -53,7 +53,7 @@ function directoryMayOverride(dir: string, accountHomePath: string): Promise { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts index 89614e66e6fd..8969a13eba29 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts @@ -5,11 +5,11 @@ // definition through them and never branches on the agent's name. import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' +import type { AgentSessionStoredAgent } from '../../../shared/agent-session-stored-agent' import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' -export type StructuredAgentDefinition = { - /** The Orca agent whose sessions this definition describes. */ - agent: string +/** `agent` names the Orca agent whose sessions this describes; the storage fields bound its records. */ +export type StructuredAgentDefinition = AgentSessionStoredAgent & { capabilities: AgentSessionCapabilities /** How a session's options read and change while no child runs. */ restingOptions: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 491702059c7c..ba83184ec405 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -130,6 +130,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi definition = (agent: string): StructuredAgentDefinition | null => this.registrations.get(agent)?.definition ?? null + definitions = (): readonly StructuredAgentDefinition[] => + [...this.registrations.values()].map((registration) => registration.definition) + stopBackgroundTasks: NonNullable = ( input ) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 9afd50481d2e..ad29ef58e651 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -327,6 +327,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** The definition registered for `agent`, null for an agent this runtime does not drive. Answered * by the router from its registrations, never by a single adapter. */ definition?(agent: string): StructuredAgentDefinition | null + /** Every registered definition, in registration order: what the host publishes to clients. */ + definitions?(): readonly StructuredAgentDefinition[] /** Stops exactly the tasks `taskIds` names, which the host resolves from its child records. */ stopBackgroundTasks?(input: { sessionId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 415e261ab3ca..f31e48c1eef3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -8,7 +8,7 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { - AgentSessionHandleProvider, + StructuredAgentId, AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' import { @@ -56,8 +56,8 @@ import { structuredAgentSessionRefusalMessage } from './structured-agent-session export type AgentSessionAttachParams = { envelope: AgentSessionMutationEnvelope location: AgentSessionExecutionLocation - provider: AgentSessionHandleProvider - agent: AgentSessionHandleProvider + provider: StructuredAgentId + agent: StructuredAgentId accountHome: AgentSessionAccountHome /** Always `native`; kept on the params because the operation fingerprint covers it. */ runtimeKind: 'native' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 1f456038f8d7..09ff44c73035 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -18,7 +18,7 @@ import type { AgentSessionAttachParams } from './structured-agent-session-attach import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' import type { AgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' export type StructuredAgentSessionCaller = { callerKey: string } @@ -29,7 +29,7 @@ export type StructuredAgentSessionCaller = { callerKey: string } export type StructuredAgentSessionReveal = { sessionId: string workspaceId: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId readable: boolean } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 5b5a4d19c3c1..69e7d2792af6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -1,4 +1,5 @@ import type { AgentSessionRewindParams } from '../../../shared/agent-session-rewind' +import type { StructuredAgentDefinition } from './structured-agent-definition' import { rewindStructuredAgentSession } from './structured-agent-session-rewind' import { StructuredConversationCommandController } from './structured-conversation-command-controller' // Structured agent-session host: where the lease, journal, and provider adapter meet. @@ -215,6 +216,11 @@ export class StructuredAgentSessionHost { supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) + /** What this runtime registered: one agent's definition (null when unregistered), or all of them. */ + agentDefinition = (agent: string) => this.deps.adapter.definition?.(agent) ?? null + agentDefinitions = (): readonly StructuredAgentDefinition[] => + this.deps.adapter.definitions?.() ?? [] + private readonly tabs = sessionTabs.createStructuredAgentSessionTabSurface( this, this.sessions, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index 77413f456340..71888797d773 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -11,6 +11,7 @@ import { type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' +import { isTuiAgent } from '../../../shared/tui-agent-config' import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure' import { @@ -158,7 +159,9 @@ export function structuredAgentSessionFailureWordsContext( ): AgentSessionFailureWordsContext { const command = journal && structuredAgentSessionAwaitedCommand(journal) return { - ...(record ? { agentName: TUI_AGENT_DISPLAY_NAMES[record.provider] } : {}), + ...(record && isTuiAgent(record.provider) + ? { agentName: TUI_AGENT_DISPLAY_NAMES[record.provider] } + : {}), ...(command ? { command } : {}) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts index 10a447292627..52b7df745469 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts @@ -6,7 +6,7 @@ // to change the minute a "no update" reading shows. The background-task channel, which only an // open chat subscribes to, carries every tick. -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-wire' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' @@ -31,7 +31,7 @@ export type StructuredStatusChildWork = { * the two cannot disagree. */ export function structuredStatusChildWork( views: readonly AgentChildWorkView[] | undefined, - provider: AgentSessionHandleProvider + provider: StructuredAgentId ): StructuredStatusChildWork { const running = views ? structuredRunningChildWork(views) : [] if (running.length === 0) { diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index a466d00d2b5e..6bc8f20925f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -22,7 +22,7 @@ import { type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { carryQueuedMessagesToClearReplacement } from './structured-agent-session-queued-mutations' -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' /** A command's `error` is the sentence its row shows. */ export function conversationCommandFailure( @@ -44,7 +44,7 @@ export type ConversationReplacement = { sourceSessionId: string sessionId: string workspaceId: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId } const clearFingerprintOf = (sessionId: string) => diff --git a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts index 9a73ae2d8c0e..e21f84821f3b 100644 --- a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts @@ -1,10 +1,10 @@ import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' export type StructuredProviderSessionOwnership = { sessionId: string workspaceId: string - provider: AgentSessionHandleProvider + provider: StructuredAgentId providerSessionId: string lease: AgentSessionLease } diff --git a/src/main/native-chat/structured-agent-session-create-support.ts b/src/main/native-chat/structured-agent-session-create-support.ts index a6323db075a1..b3bdd441760f 100644 --- a/src/main/native-chat/structured-agent-session-create-support.ts +++ b/src/main/native-chat/structured-agent-session-create-support.ts @@ -2,6 +2,8 @@ import type { AgentSessionExecutionLocation } from '../../shared/agent-session-r import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { GlobalSettings } from '../../shared/global-settings-types' import { hasExplicitTuiLaunchCommand } from '../../shared/tui-agent-launch-command-override' +import { isTuiAgent } from '../../shared/tui-agent-config' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' import { readClaudeManagedAccountGateSettings, structuredClaudeMatchesActiveManagedAccount, @@ -19,7 +21,7 @@ export type StructuredAgentSessionCreateSupport = { * however wrong it was. The runtime hands over the two facts it owns and this decides. */ export function resolveStructuredAgentSessionCreateSupport(input: { - agent: 'claude' | 'codex' + agent: StructuredAgentId location: AgentSessionExecutionLocation adapterSupportsCreate: boolean getSettings: () => ClaudeManagedAccountGateSettings & @@ -38,7 +40,10 @@ export function resolveStructuredAgentSessionCreateSupport(input: { } // This host's own launch command override names a process only a terminal runs, whichever // client asked; a client routes on its own override for its own machine only. - if (hasExplicitTuiLaunchCommand(readSettingsOrNull(input.getSettings), input.agent)) { + if ( + isTuiAgent(input.agent) && + hasExplicitTuiLaunchCommand(readSettingsOrNull(input.getSettings), input.agent) + ) { return { supported: false, reason: 'agent' } } // Claude only: Codex resolves its account on a different path, so its answer is untouched here. diff --git a/src/main/runtime/agent-session-legacy-record-import.ts b/src/main/runtime/agent-session-legacy-record-import.ts index f632f008e764..d92a9254c8de 100644 --- a/src/main/runtime/agent-session-legacy-record-import.ts +++ b/src/main/runtime/agent-session-legacy-record-import.ts @@ -26,6 +26,7 @@ import { type AgentSessionStoreImportRows } from './agent-session-record-rows' import { isReadableAgentSessionStoreRecord } from './agent-session-store-row-rules' +import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' export type LegacyAgentSessionRecords = | { kind: 'absent' } @@ -52,10 +53,15 @@ function readFailedTransiently(error: unknown): boolean { /** Never throws. */ export async function readLegacyAgentSessionRecords( stateDirectory: string, - hostId: string + hostId: string, + agents: AgentSessionStoredAgents ): Promise { try { - const loaded = await loadAgentSessionStore(legacyAgentSessionStorePath(stateDirectory), hostId) + const loaded = await loadAgentSessionStore( + legacyAgentSessionStorePath(stateDirectory), + hostId, + agents + ) return loaded.storeFound ? { kind: 'loaded', state: loaded.state, fromBackup: loaded.recoveredFromBackup } : { kind: 'absent' } @@ -96,7 +102,8 @@ function withFloorAboveSetAsideCopy(record: AgentSessionRecord, raw: unknown): A function importRows( state: AgentSessionStoreState, - fromBackup: boolean + fromBackup: boolean, + agents: AgentSessionStoredAgents ): AgentSessionStoreImportRows { const records = new Map(state.records) if (fromBackup) { @@ -113,7 +120,7 @@ function importRows( // A record the load rules refuse is kept as its bytes, which every load then sets aside. rows.push([ sessionId, - isReadableAgentSessionStoreRecord(sessionId, JSON.parse(json)) + isReadableAgentSessionStoreRecord(sessionId, JSON.parse(json), agents) ? json : JSON.stringify(encodeAgentSessionRecord(loaded)) ]) @@ -137,6 +144,7 @@ function importRows( export function legacyAgentSessionRecordImport( legacy: LegacyAgentSessionRecords, hostId: string, + agents: AgentSessionStoredAgents, report: (report: LegacyAgentSessionRecordImportReport) => void ): JournalLegacyRecordImport { switch (legacy.kind) { @@ -153,7 +161,7 @@ export function legacyAgentSessionRecordImport( } let rows: AgentSessionStoreImportRows try { - rows = importRows(legacy.state, legacy.fromBackup) + rows = importRows(legacy.state, legacy.fromBackup, agents) } catch (error) { report({ kind: 'unusable', error }) return NO_LEGACY_JOURNAL_RECORDS diff --git a/src/main/runtime/agent-session-record-rows.ts b/src/main/runtime/agent-session-record-rows.ts index 1b1fe6e1b3b2..8f998e2a4a79 100644 --- a/src/main/runtime/agent-session-record-rows.ts +++ b/src/main/runtime/agent-session-record-rows.ts @@ -24,6 +24,7 @@ import { isReadableRetiredAgentSessionClaimKey } from './agent-session-store-row-rules' import { AgentSessionTabTable, type PersistedAgentSessionTab } from './agent-session-tab-table' +import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' /** "Never recorded" and "recorded, now empty" differ, and #17439's legacy fallback needs the first; * no row count can say which. */ @@ -63,8 +64,8 @@ function parseJson(json: string | null): { ok: true; value: unknown } | { ok: fa } } -function unreadableRecordReason(value: unknown): string { - if (isPersistedAgentSessionRecord(value)) { +function unreadableRecordReason(value: unknown, agents: AgentSessionStoredAgents): string { + if (isPersistedAgentSessionRecord(value, agents)) { return 'record_key_session_id_mismatch' } const schemaVersion = @@ -83,7 +84,8 @@ function unreadableRecordReason(value: unknown): string { */ export function loadAgentSessionStoreRows( db: Database.Database, - hostId: string + hostId: string, + agents: AgentSessionStoredAgents ): AgentSessionStoreState { const state: AgentSessionStoreState = { schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, @@ -111,14 +113,17 @@ export function loadAgentSessionStoreRows( } const parsed = parseJson(text(row, 'record_json')) const value = parsed.ok ? parsed.value : text(row, 'record_json') - if (parsed.ok && isReadableAgentSessionStoreRecord(sessionId, value)) { + if (parsed.ok && isReadableAgentSessionStoreRecord(sessionId, value, agents)) { const { record } = decodePersistedAgentSessionRecord(value) state.records.set(sessionId, { ...record, lease: { ...withoutRetiredLeaseLatches(record.lease), unreconciled: true } }) } else { - state.unreadableRecords.set(sessionId, { reason: unreadableRecordReason(value), raw: value }) + state.unreadableRecords.set(sessionId, { + reason: unreadableRecordReason(value, agents), + raw: value + }) } } for (const row of db diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index 6b5632a87abf..4c37bfa17ce9 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -18,6 +18,7 @@ import { } from '../../shared/agent-session-record' import { decodePersistedAgentSessionRecord } from '../../shared/agent-session-record-stored-form' import { parseAgentSessionTabTable, type AgentSessionTabTable } from './agent-session-tab-table' +import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const @@ -74,7 +75,11 @@ function emptyState(hostId: string): AgentSessionStoreState { } } -function parseState(raw: string, hostId: string): Pick | null { +function parseState( + raw: string, + hostId: string, + agents: AgentSessionStoredAgents +): Pick | null { let parsed: unknown try { parsed = JSON.parse(raw) @@ -129,7 +134,7 @@ function parseState(raw: string, hostId: string): Pick { const missing = [...state.unreadableRecords.keys()].filter( (sessionId) => !state.records.has(sessionId) @@ -234,7 +240,7 @@ async function salvageUnreadableRecordsFromBackup( } catch { return } - const backup = parseState(raw, hostId) + const backup = parseState(raw, hostId, agents) if (!backup) { return } @@ -248,7 +254,8 @@ async function salvageUnreadableRecordsFromBackup( export async function loadAgentSessionStore( filePath: string, - hostId: string + hostId: string, + agents: AgentSessionStoredAgents ): Promise { let unusableStoreFound = false for (const [candidate, recoveredFromBackup] of [ @@ -267,13 +274,13 @@ export async function loadAgentSessionStore( } continue } - const parsed = parseState(raw, hostId) + const parsed = parseState(raw, hostId, agents) if (!parsed) { unusableStoreFound = true continue } if (!recoveredFromBackup) { - await salvageUnreadableRecordsFromBackup(parsed.state, backupPath(filePath), hostId) + await salvageUnreadableRecordsFromBackup(parsed.state, backupPath(filePath), hostId, agents) } return { ...parsed, diff --git a/src/main/runtime/agent-session-record-store-test-harness.ts b/src/main/runtime/agent-session-record-store-test-harness.ts index ab879fa159b0..e90aad5497f0 100644 --- a/src/main/runtime/agent-session-record-store-test-harness.ts +++ b/src/main/runtime/agent-session-record-store-test-harness.ts @@ -12,6 +12,8 @@ import { isAgentSessionProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' +import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' import { JOURNAL_DB_SCHEMA_VERSION } from '../native-chat/agent-session-journal/journal-database-schema' import { closeTestJournalHostDatabase, @@ -47,11 +49,12 @@ function databaseFor(stateDirectory: string): Database.Database { /** Opens, or reopens, the store in `stateDirectory`: what a fresh app process does at launch. */ export async function openTestAgentSessionRecordStore( stateDirectory: string, - options: { hostId?: string } = {} + options: { hostId?: string; agents?: AgentSessionStoredAgents } = {} ): Promise { return AgentSessionRecordStore.open({ journalDatabase: openTestJournalHostDatabase(stateDirectory), - hostId: options.hostId ?? TEST_HOST_ID + hostId: options.hostId ?? TEST_HOST_ID, + agents: options.agents ?? CLAUDE_AND_CODEX_STORED_AGENTS }) } diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index cd87b024c0fd..f61c27012c8a 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -70,6 +70,7 @@ import { setAgentSessionTabVisibility, showAgentSessionTabs } from './agent-sess import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { loadAgentSessionStoreRows } from './agent-session-record-rows' import { AgentSessionStoreTransactions } from './agent-session-store-transactions' +import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' export const AGENT_SESSION_LEASE_TTL_MS = 30_000, AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000 @@ -83,14 +84,16 @@ export class AgentSessionRecordStore { readonly hostId: string ) {} - /** Reads every row once; nothing re-reads them. `hostId` is the execution host this runtime is. */ + /** Reads every row once; nothing re-reads them. `hostId` is the execution host this runtime is; + * `agents` are the agents it registered, the only ones whose records it reads or writes. */ static open(args: { journalDatabase: JournalHostDatabase hostId: string + agents: AgentSessionStoredAgents }): AgentSessionRecordStore { - const loaded = loadAgentSessionStoreRows(args.journalDatabase.db, args.hostId) + const loaded = loadAgentSessionStoreRows(args.journalDatabase.db, args.hostId, args.agents) return new AgentSessionRecordStore( - new AgentSessionStoreTransactions(args.journalDatabase, loaded), + new AgentSessionStoreTransactions(args.journalDatabase, loaded, args.agents), args.hostId ) } diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index db5707d3b4d8..d36d87b0e993 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -38,7 +38,7 @@ import { isAgentSessionLaunchArgs } from '../../shared/agent-session-launch-args import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' import { agentSessionProviderHandleRoot, - type AgentSessionHandleProvider, + type StructuredAgentId, type AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { @@ -51,7 +51,7 @@ import { agentSessionRecordIdentityFields } from './agent-session-record-foundin export type AgentSessionReserveRequest = { sessionId: string location: AgentSessionExecutionLocation - provider: AgentSessionHandleProvider + provider: StructuredAgentId accountHome: AgentSessionAccountHome /** Arguments pinned on first reservation so owner replacement repeats the same launch. */ launchArgs?: AgentSessionLaunchArgs diff --git a/src/main/runtime/agent-session-store-draft.ts b/src/main/runtime/agent-session-store-draft.ts index e39027d0042a..616ff60b2ca5 100644 --- a/src/main/runtime/agent-session-store-draft.ts +++ b/src/main/runtime/agent-session-store-draft.ts @@ -15,6 +15,7 @@ import { isReadableRetiredAgentSessionClaimKey } from './agent-session-store-row-rules' import type { PersistedAgentSessionTab } from './agent-session-tab-table' +import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' /** Unreadable rows are derived at load and never written, so the draft shares them. */ export function draftAgentSessionStoreState(state: AgentSessionStoreState): AgentSessionStoreState { @@ -85,12 +86,13 @@ function retiredClaimKeysChanged( */ export function agentSessionStoreDraftRowWrites( published: AgentSessionStoreState, - draft: AgentSessionStoreState + draft: AgentSessionStoreState, + agents: AgentSessionStoredAgents ): AgentSessionStoreRowWrites | null { const records = serializeChangedRows( published.records, draft.records, - (sessionId, written) => isReadableAgentSessionStoreRecord(sessionId, written), + (sessionId, written) => isReadableAgentSessionStoreRecord(sessionId, written, agents), encodeAgentSessionRecord ) const operations = serializeChangedRows(published.operations, draft.operations, (key, written) => diff --git a/src/main/runtime/agent-session-store-row-rules.ts b/src/main/runtime/agent-session-store-row-rules.ts index 6d2f4ddf6b2e..6f9c5fccf158 100644 --- a/src/main/runtime/agent-session-store-row-rules.ts +++ b/src/main/runtime/agent-session-store-row-rules.ts @@ -12,15 +12,17 @@ import { import { isAgentSessionId, isPersistedAgentSessionRecord } from '../../shared/agent-session-record' import type { PersistedAgentSessionRecord } from '../../shared/agent-session-legacy-handoff-lease' import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' +import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' import type { RetiredAgentSessionClaimKey } from './agent-session-record-store-file' import type { PersistedAgentSessionTab } from './agent-session-tab-table' -/** A record row a load keeps in `records` rather than quarantining. */ +/** A record row a load keeps in `records` rather than quarantining: one of a registered agent. */ export function isReadableAgentSessionStoreRecord( sessionId: string, - value: unknown + value: unknown, + agents: AgentSessionStoredAgents ): value is PersistedAgentSessionRecord { - return isPersistedAgentSessionRecord(value) && value.sessionId === sessionId + return isPersistedAgentSessionRecord(value, agents) && value.sessionId === sessionId } export function isReadableAgentSessionStoreOperation( diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index 879ff4a160e8..d0bcb7197d5f 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -17,6 +17,7 @@ import { draftAgentSessionStoreState, type AgentSessionStoreRowWrites } from './agent-session-store-draft' +import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' // Why: rows are diffed by identity, so a row changed in place would never be written. Tests and // development builds make that a TypeError; packaged builds skip the walk. @@ -75,7 +76,8 @@ export class AgentSessionStoreTransactions { constructor( private readonly journalDatabase: JournalHostDatabase, - loaded: AgentSessionStoreState + loaded: AgentSessionStoreState, + private readonly agents: AgentSessionStoredAgents ) { freezeRows(loaded, null) this.published = loaded @@ -122,7 +124,7 @@ export class AgentSessionStoreTransactions { const published = this.published const draft = draftAgentSessionStoreState(published) const result = apply(draft) - const writes = agentSessionStoreDraftRowWrites(published, draft) + const writes = agentSessionStoreDraftRowWrites(published, draft, this.agents) return { result, writes, diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 1caa929185b6..d3bf0e191343 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -28,29 +28,97 @@ import { agentSessionAccountHome, type AgentSessionAccountHome } from '../../shared/agent-session-account-home' -import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle' +import { + isAgentSessionHandleProvider, + type StructuredAgentId +} from '../../shared/agent-session-provider-handle' +import { agentSessionWireProviderHandle } from '../../shared/agent-session-provider-handle-encoding' export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { async getStructuredAgentSessionCreateSupport( worktreeSelector: string, - agent: AgentSessionHandleProvider + agent: StructuredAgentId ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) return resolveStructuredAgentSessionCreateSupport({ agent, location, - adapterSupportsCreate: - agent === 'claude' - ? supportsClaudeStructuredLocation(location) - : supportsCodexStructuredLocation(location), + adapterSupportsCreate: await this.structuredAgentSupportsLocation(agent, location), getSettings: () => this.requireStore().getSettings() }) } + /** Claude and Codex answer from their own location rules without installing the host, as they + * always have; any other agent is the answer of the adapter the host registered for it. */ + protected async structuredAgentSupportsLocation(agent: StructuredAgentId, location) { + if (agent === 'claude') { + return supportsClaudeStructuredLocation(location) + } + if (agent === 'codex') { + return supportsCodexStructuredLocation(location) + } + await ensureStructuredAgentSessionHostUnlessRefused(() => + this.ensureStructuredAgentSessionHost() + ) + return getStructuredAgentSessionHost()?.supportsCreate(location, agent) ?? false + } + + /** Where a launch of `agent` finds its account, resolved on this host; null for an agent with no + * resolver here, whose create is refused. Claude and Codex are the agents with one today. */ + protected structuredAgentAccountHomePathResolver( + agent: StructuredAgentId, + worktree: string, + purpose: 'launch' | 'read' + ) { + if (agent === 'claude') { + return async ({ launchEnv, location }) => + resolveStructuredClaudeAccountHomePath({ + launchEnv, + wslDistro: location?.wslDistro ?? null, + getClaudeConfigDirectory: (target) => this.accounts.getClaudeConfigDirectory(target) + }) + } + if (agent === 'codex') { + if (purpose === 'read') { + // Read-only resolver, never launch prep: a picker mount or discovery read + // must not sync homes, start bridges, or clear an account selection. + return async ({ launchEnv }) => + resolveStructuredCodexAccountHomePath({ + launchEnv, + resolveLaunchHome: this.resolveCodexStructuredLaunchHomeFn + }) + } + return async ({ launchEnv }) => { + await applyStructuredCodexWorkspaceTrust({ + workspacePath: (await this.resolveRuntimeFileTarget(worktree)).worktree.path, + launchEnv, + settings: this.requireStore().getSettings() + }) + return resolveStructuredCodexAccountHomePath({ + launchEnv, + resolveLaunchHome: this.prepareCodexStructuredLaunchFn + }) + } + } + return null + } + + /** The definition the installed host registered for `agent`: what its account home pins. */ + protected async requireRegisteredStructuredAgent(agent: StructuredAgentId) { + await this.ensureStructuredAgentSessionHost() + const definition = getStructuredAgentSessionHost()?.agentDefinition(agent) ?? null + if (!definition) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } + return definition + } + /** The saved selection a new chat here starts with. createSupport reports it too, so a client's * picker shows what create will run; one resolver keeps the two from drifting. */ structuredAgentSessionLaunchSeedOptions( - agent: AgentSessionHandleProvider + agent: StructuredAgentId ): Record | undefined { return resolveStructuredLaunchSeedOptions( this.requireStore().getSettings().nativeChatSessionOptions, @@ -98,30 +166,21 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR async resolveStructuredAgentSessionCreateIntent(input: { envelope: { sessionId: string; clientOperationId: string } worktree: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId callerKey?: string resumeFrom?: { providerSessionId: string } }): Promise { - if (input.agent === 'claude') { - return this.resolveStructuredAgentSessionIntent(input, async ({ launchEnv, location }) => - resolveStructuredClaudeAccountHomePath({ - launchEnv, - wslDistro: location.wslDistro, - getClaudeConfigDirectory: (target) => this.accounts.getClaudeConfigDirectory(target) - }) - ) - } - return this.resolveStructuredAgentSessionIntent(input, async ({ launchEnv }) => { - await applyStructuredCodexWorkspaceTrust({ - workspacePath: (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path, - launchEnv, - settings: this.requireStore().getSettings() - }) - return resolveStructuredCodexAccountHomePath({ - launchEnv, - resolveLaunchHome: this.prepareCodexStructuredLaunchFn + const resolveAccountHomePath = this.structuredAgentAccountHomePathResolver( + input.agent, + input.worktree, + 'launch' + ) + if (!resolveAccountHomePath) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' }) - }) + } + return this.resolveStructuredAgentSessionIntent(input, resolveAccountHomePath) } /** @@ -130,38 +189,27 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR * Same resolver as the create intent above — never a second copy. */ async resolveStructuredAgentAccountHome( - agent: AgentSessionHandleProvider + agent: StructuredAgentId ): Promise { + const resolvePath = this.structuredAgentAccountHomePathResolver(agent, '', 'read') + if (!resolvePath) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } + const definition = await this.requireRegisteredStructuredAgent(agent) const launchEnv = resolveTuiAgentLaunchEnv( agent, this.requireStore().getSettings().agentDefaultEnv ) - if (agent === 'claude') { - return agentSessionAccountHome( - agent, - resolveStructuredClaudeAccountHomePath({ - launchEnv, - wslDistro: null, - getClaudeConfigDirectory: (target) => this.accounts.getClaudeConfigDirectory(target) - }) - ) - } - return agentSessionAccountHome( - agent, - // Read-only resolver, never launch prep: a picker mount or discovery read - // must not sync homes, start bridges, or clear an account selection. - await resolveStructuredCodexAccountHomePath({ - launchEnv, - resolveLaunchHome: this.resolveCodexStructuredLaunchHomeFn - }) - ) + return agentSessionAccountHome(definition, await resolvePath({ launchEnv, location: null })) } protected async resolveStructuredAgentSessionIntent( input: { envelope: { sessionId: string; clientOperationId: string } worktree: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId callerKey?: string resumeFrom?: { providerSessionId: string } }, @@ -176,7 +224,9 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR }) => string | Promise ): Promise { const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) - if (!support.supported) { + // Adopting a conversation reads the agent's own transcript, which only Claude and Codex have + // importers for. + if (!support.supported || (input.resumeFrom && !isAgentSessionHandleProvider(input.agent))) { throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostUnsupported' }) @@ -185,6 +235,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR const launchEnv = resolveTuiAgentLaunchEnv(input.agent, settings.agentDefaultEnv) const options = this.structuredAgentSessionLaunchSeedOptions(input.agent) const location = await this.resolveStructuredAgentSessionLocation(input.worktree) + const definition = await this.requireRegisteredStructuredAgent(input.agent) const host = getStructuredAgentSessionHost() const committedReplay = resolveCommittedStructuredAgentSessionAdoptionIntent({ host, @@ -220,10 +271,10 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR location, provider: input.agent, agent: input.agent, - accountHome: { - variable: input.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', - path: adoption ? adoption.accountHomePath : selectedAccountHomePath - }, + accountHome: agentSessionAccountHome( + definition, + adoption ? adoption.accountHomePath : selectedAccountHomePath + ), ...(options ? { options } : {}), ...(input.resumeFrom && adoption ? { @@ -231,14 +282,11 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR // `providerHandle` alone must not: `agentSession.ensure` already passes one today // without adopting anything. adopt: { - providerHandle: - input.agent === 'claude' - ? { - kind: 'claude' as const, - sessionId: input.resumeFrom.providerSessionId, - leafUuid: null - } - : { kind: 'codex' as const, threadId: input.resumeFrom.providerSessionId }, + providerHandle: agentSessionWireProviderHandle({ + transport: definition.handleTransport, + agent: definition.agent, + nativeId: input.resumeFrom.providerSessionId + }), transcriptPath: adoption.transcriptPath } } diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 61c2e0b3605f..425c07e0502b 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -27,6 +27,7 @@ import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { isWslUncPath } from '../../shared/wsl-paths' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' import type { PtyProcessInspection } from '../providers/pty-process-inspection' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRuntimeWithGetStructuredAgentSessionCreateSupport { /** Projects only: a replacement's chat already has its tab in the store, which the /clear commit @@ -74,7 +75,8 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() const restored = (host?.listSessionTabs() ?? []).flatMap((session) => { - if (session.agent !== 'codex' && session.agent !== 'claude') { + // A tab names an agent only this host registered; the record store reads no other's records. + if (!host?.agentDefinition(session.agent)) { return [] } let sessionId = session.sessionId @@ -111,7 +113,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu async publishStructuredAgentSessionTab(input: { workspaceId: string sessionId: string - agent: 'claude' | 'codex' + agent: StructuredAgentId activate: boolean notify?: boolean replacesSessionId?: string @@ -139,7 +141,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu projectStructuredAgentSessionTab(input: { workspaceId: string sessionId: string - agent: 'claude' | 'codex' + agent: StructuredAgentId activate: boolean notify?: boolean replacesSessionId?: string diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 3376d9bf994c..267b201edf90 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -46,6 +46,7 @@ import { PAIRING_METHODS } from './pairing' import { UPDATER_METHODS } from './updater' import { AGENT_SESSION_METHODS } from './agent-session' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { STRUCTURED_AGENT_SESSION_AGENTS_METHODS } from './structured-agent-session-agents' import { ARTIFACT_METHODS } from './artifacts' import { AGENT_HOOK_METHODS } from './agent-hooks' import { AGENT_LAUNCH_METHODS } from './agent-launch' @@ -63,6 +64,7 @@ export const ALL_RPC_METHODS = [ ...WORKTREE_METHODS, ...AGENT_SESSION_METHODS, ...STRUCTURED_AGENT_SESSION_METHODS, + ...STRUCTURED_AGENT_SESSION_AGENTS_METHODS, ...AGENT_LAUNCH_METHODS, ...TERMINAL_METHODS, ...TERMINAL_ORPHAN_METHODS, diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts index 5686d8135592..d088a13e44f3 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts @@ -1,6 +1,7 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../../shared/protocol-version' @@ -25,9 +26,15 @@ function clientCanRenderStructuredAgentSessionTab( if (!clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) { return false } - return ( - tab.agent === 'codex' || - clientCapabilities.includes(CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + if (tab.agent === 'codex') { + return true + } + // Every shipped client reads only Claude and Codex as chats; any other agent's tab would list + // with an empty pane, so it waits for a client that says it renders the host's agents. + return clientCapabilities.includes( + tab.agent === 'claude' + ? CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + : STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY ) } @@ -65,15 +72,14 @@ export function projectSessionTabAgentStatus true) - // Why: a paired client renders only codex structured tabs unless it says otherwise - // (mobile's resolveMobileNativeChat returns null for every other agent), so an - // ungated row would list and select into a pane that shows neither chat nor terminal. - if ( - structuredVisible && - clientKind !== undefined && - !clientCapabilities?.includes(CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) - ) { - projected = projectAgentSessionTabsOut(projected, (tab) => tab.agent !== 'codex') + // Why: a paired client renders only the agents it says it does (mobile's + // resolveMobileNativeChat returns null for every other agent), so an ungated row would + // list and select into a pane that shows neither chat nor terminal. + if (structuredVisible && clientKind !== undefined) { + projected = projectAgentSessionTabsOut( + projected, + (tab) => !clientCanRenderStructuredAgentSessionTab(tab, clientCapabilities) + ) } } // Why: only paired runtimes have legacy `done` completion side effects; mobile must keep its row without changing the exact v2 auth shape. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-agents.ts b/src/main/runtime/rpc/methods/structured-agent-session-agents.ts new file mode 100644 index 000000000000..6d03767d79f0 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-agents.ts @@ -0,0 +1,23 @@ +// `agentSession.agents` — the structured agents this host registered, with each agent's declared +// capability record, so a client can show what an agent supports before a session of it exists. +// +// Additive and negotiated: a client calls it only once the host advertises +// `agent-session.structured.registered-agents.v1`; without it, a client knows Claude and Codex only. + +import type { AgentSessionAgentsResult } from '../../../../shared/agent-session-registered-agents' +import { AGENT_SESSION_AGENTS_METHOD } from '../../../../shared/agent-session-registered-agents' +import { defineMethod } from '../core' +import { requireInstalledStructuredHost } from './structured-agent-session-gate' +import { AgentsParams } from './structured-agent-session-schemas' + +export const STRUCTURED_AGENT_SESSION_AGENTS_METHODS = [ + defineMethod({ + name: AGENT_SESSION_AGENTS_METHOD, + params: AgentsParams, + handler: async (_params, ctx): Promise => ({ + agents: (await requireInstalledStructuredHost(ctx)) + .agentDefinitions() + .map(({ agent, capabilities }) => ({ agent, capabilities: { ...capabilities } })) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 1d0899a014df..af82880543fb 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -27,6 +27,7 @@ import type { StructuredAgentSessionHost } from '../../../native-chat/agent-sess import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import type { StructuredAgentSessionResumeSource } from '../../../../shared/structured-agent-session-create' import type { OrcaRuntimeService } from '../../orca-runtime' +import type { StructuredAgentId } from '../../../../shared/agent-session-provider-handle' import { resolveUncommittedStructuredCreate, type StructuredCreateRefused @@ -36,7 +37,7 @@ export type PreparedStructuredAgentSessionCreate = { host: StructuredAgentSessionHost attachParams: AgentSessionAttachParams /** Null when the caller supplied its own location; only a resolved worktree publishes a tab. */ - tab: { workspaceId: string; agent: 'claude' | 'codex' } | null + tab: { workspaceId: string; agent: StructuredAgentId } | null } /** @@ -73,7 +74,7 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { ensureHost: () => Promise envelope: AgentSessionMutationEnvelope worktree: string - agent: 'claude' | 'codex' + agent: StructuredAgentId caller: StructuredAgentSessionCaller resumeFrom?: StructuredAgentSessionResumeSource /** Replaces the seed options the host resolves from settings. Orchestration passes the @@ -109,13 +110,13 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { // must replay rather than conflict. ...(args.options ? { options: args.options } : {}), ...(args.tabId ? { surfaceTabId: args.tabId } : {}), - provider: resolved.provider as 'claude' | 'codex', - agent: resolved.agent as 'claude' | 'codex', + provider: resolved.provider, + agent: resolved.agent, envelope: { ...args.envelope, payloadFingerprint: hostFingerprint } }, tab: { workspaceId: resolved.location.workspaceId, - agent: resolved.agent as 'claude' | 'codex' + agent: resolved.agent } } } @@ -167,7 +168,7 @@ export async function createStructuredAgentSessionForWorktree(args: { caller: StructuredAgentSessionCaller envelope: AgentSessionMutationEnvelope worktree: string - agent: 'claude' | 'codex' + agent: StructuredAgentId activate: boolean options?: Readonly> tabId?: string diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts index cc03bfd21a5c..c6403d609488 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -1,5 +1,7 @@ +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import { STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { OrcaRuntimeService } from '../../orca-runtime' @@ -21,6 +23,21 @@ export function supportsStructuredAgentSessions( ) } +/** Whether a published row naming `agent` is one this client reads. Every client knows Claude and + * Codex; any other agent reaches only a client that renders the host's registered agents. */ +export function clientReadsStructuredAgent( + context: Pick, + agent: string +): boolean { + return ( + isAgentSessionHandleProvider(agent) || + context.clientKind === undefined || + context.clientCapabilities?.includes( + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) === true + ) +} + /** * COMPAT(released phones): a remote client that does not pick each launch's mode itself reads * `agentSession.createSupport` as "should this launch be a chat", which the host's setting diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts index a48f897a005e..86f3e3f59ec6 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 @@ -12,6 +12,13 @@ import { structuredCallerFor } from './structured-agent-session-gate' import { RestartResumableParams, RestartResumeParams } from './structured-agent-session-schemas' +import { clientReadsStructuredAgent } from './structured-agent-session-policy' +import type { RpcContext } from '../core' + +/** Offers for agents this client cannot show stay on the host for a client that can. */ +function readableOffers(ctx: RpcContext, offers: readonly T[]): T[] { + return offers.filter((offer) => clientReadsStructuredAgent(ctx, offer.agent)) +} export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ defineMethod({ @@ -21,9 +28,9 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ await ensureStructuredHostInstalled(ctx) const host = requireStructuredHost(ctx) return { - sessions: await host.restartResume.list(), + sessions: readableOffers(ctx, await host.restartResume.list()), // Acted-on offers whose agent did not carry on. Optional on the wire; older clients ignore it. - failed: await host.restartResume.listFailures() + failed: readableOffers(ctx, await host.restartResume.listFailures()) } } }), @@ -43,8 +50,8 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ } return { dismissed, - sessions: await host.restartResume.list(), - failed: await host.restartResume.listFailures() + sessions: readableOffers(ctx, await host.restartResume.list()), + failed: readableOffers(ctx, await host.restartResume.listFailures()) } } }), diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 5930fa1e80fa..973fcc5e65b4 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -3,6 +3,7 @@ // Strict objects throughout: zod drops unknown keys, and a silently dropped key // is how a newer client's field becomes a different effect on an older host. export { + AgentsParams, AttachParams, CancelParams, ConversationCommandParams, diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index e0d24119086c..4ee06cd83542 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -85,8 +85,8 @@ async function resolveClientSuppliedAttach(params: z.infer, const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params const attachParams = { ...attachWithoutAgent, - provider: params.provider as 'claude' | 'codex', - agent: params.agent as 'claude' | 'codex' + provider: params.provider, + agent: params.agent } as AgentSessionAttachParams return { host, attachParams } } @@ -175,7 +175,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }, envelope: params.envelope, worktree: params.worktree, - agent: params.agent as 'claude' | 'codex', + agent: params.agent, caller: callerFor(ctx), ...(params.resumeFrom ? { resumeFrom: params.resumeFrom } : {}), ...(params.tabId ? { tabId: params.tabId } : {}) diff --git a/src/main/runtime/structured-agent-session-journal-open.ts b/src/main/runtime/structured-agent-session-journal-open.ts index 54c27fbfe963..fd9fefa8713a 100644 --- a/src/main/runtime/structured-agent-session-journal-open.ts +++ b/src/main/runtime/structured-agent-session-journal-open.ts @@ -9,6 +9,7 @@ import { type LegacyAgentSessionRecordImportReport } from './agent-session-legacy-record-import' import { recordStructuredAgentSessionHostInstallRefusal } from './structured-agent-session-host-refusal' +import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' // Every chat request retries a failed open, so each distinct failure is logged once, with its stack. @@ -34,13 +35,16 @@ function logOpenFailureOnce(logger: StructuredAgentSessionLogger, error: unknown export async function openStructuredAgentSessionJournalDatabase(args: { stateDirectory: string hostId: string + /** The registered agents: a legacy record of any other agent is imported verbatim, unread. */ + agents: AgentSessionStoredAgents logger: StructuredAgentSessionLogger }): Promise { try { const opened = await JournalHostDatabase.open(args.stateDirectory, async () => legacyAgentSessionRecordImport( - await readLegacyAgentSessionRecords(args.stateDirectory, args.hostId), + await readLegacyAgentSessionRecords(args.stateDirectory, args.hostId, args.agents), args.hostId, + args.agents, (report) => reportLegacyRecordImport(args.logger, report) ) ) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 49665fd27c39..604b16a9107b 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -29,6 +29,7 @@ import { import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' +import { agentSessionStoredAgents } from '../../shared/agent-session-stored-agent' import { StructuredAgentSessionHost, type StructuredAgentSessionHostDeps @@ -212,6 +213,13 @@ export async function stopStructuredAgentSessionRuntime(options?: { } } +/** The agents this runtime registers. Their definitions bound what the record store reads and + * writes, and each is routed to its adapter below. */ +const STRUCTURED_AGENT_STORAGE = agentSessionStoredAgents([ + CODEX_STRUCTURED_AGENT, + CLAUDE_STRUCTURED_AGENT +]) + async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { // Why thrown rather than defaulted: the caller is `@ts-nocheck`, so a dropped // field arrives here as `undefined`. Refusing to install is loud; guessing a @@ -227,6 +235,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { const envResolvers = createStructuredAgentEnvironmentResolvers(deps) const { resolveCodexEnvironment, resolveClaudeInheritedEnv } = envResolvers - const store = AgentSessionRecordStore.open({ journalDatabase, hostId: deps.hostId }) + const store = AgentSessionRecordStore.open({ + journalDatabase, + hostId: deps.hostId, + agents: STRUCTURED_AGENT_STORAGE + }) let host: StructuredAgentSessionHost | null = null const lifecycle = createStructuredAgentSessionLifecycleDelivery({ handle: (event) => host?.handleAdapterEvent(event), diff --git a/src/main/runtime/structured-session-worktree-teardown.ts b/src/main/runtime/structured-session-worktree-teardown.ts index 047b4a216445..f0d814637ed0 100644 --- a/src/main/runtime/structured-session-worktree-teardown.ts +++ b/src/main/runtime/structured-session-worktree-teardown.ts @@ -35,10 +35,11 @@ import { closeStructuredAgentSessionChild } from './structured-agent-session-clo import { retireSettledStructuredWorkerTab } from './structured-agent-session-tab-retirement' import type { WorktreePtyHostFence } from './worktree-pty-host-fence' import type { OrcaRuntimeService } from './orca-runtime' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' export type StructuredSessionInWorkspace = { sessionId: string - agent: 'claude' | 'codex' + agent: StructuredAgentId } export type UnclosedStructuredSession = StructuredSessionInWorkspace & { diff --git a/src/main/runtime/structured-worker-authority.ts b/src/main/runtime/structured-worker-authority.ts index 24fda6b1456e..8f08b4378d3d 100644 --- a/src/main/runtime/structured-worker-authority.ts +++ b/src/main/runtime/structured-worker-authority.ts @@ -15,6 +15,7 @@ import type { RuntimeTerminalState } from '../../shared/runtime-types' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrchestrationDb } from './orchestration/db' import { structuredWorkerAddressable } from './structured-worker-custody' +import { isAgentSessionHandleProvider } from '../../shared/agent-session-provider-handle' import { isStructuredWorkerHandle, structuredWorkerIdentities, @@ -115,9 +116,9 @@ export function resolveStructuredWorkerAuthority( * reconciler stamps the frozen journal archive with whatever it is told here. */ export function structuredWorkerAgent(identity: StructuredWorkerIdentity): 'claude' | 'codex' { - return ( - identity.agent ?? readStructuredAgentSessionRecord(identity.sessionId)?.provider ?? 'claude' - ) + // Workers are Claude or Codex sessions only: dispatch refuses any other agent. + const provider = readStructuredAgentSessionRecord(identity.sessionId)?.provider + return identity.agent ?? (isAgentSessionHandleProvider(provider) ? provider : 'claude') } export type StructuredWorkerObservation = { diff --git a/src/shared/agent-session-account-home.ts b/src/shared/agent-session-account-home.ts index 2a9be3d1f12a..f33f70347583 100644 --- a/src/shared/agent-session-account-home.ts +++ b/src/shared/agent-session-account-home.ts @@ -2,12 +2,12 @@ * The account an agent session is pinned to: the agent's config directory, and the environment * variable that points the agent at it. * - * The variable is the agent's own (Claude reads `CLAUDE_CONFIG_DIR`, Codex `CODEX_HOME`), so a - * record stores it beside the path and a new agent names its own here. Stored values are exactly - * what older builds wrote and read. + * The variable is the agent's own (Claude reads `CLAUDE_CONFIG_DIR`, Codex `CODEX_HOME`), declared + * by its definition, so a record stores it beside the path. Stored values are exactly what older + * builds wrote and read. */ -import type { AgentSessionHandleProvider } from './agent-session-provider-handle' +import type { AgentSessionStoredAgent } from './agent-session-stored-agent' /** Account root pinned at launch by the account selector, so a resume cannot drift to another login. */ export type AgentSessionAccountHome = { @@ -17,29 +17,10 @@ export type AgentSessionAccountHome = { path: string } -const CONFIG_DIRECTORY_VARIABLES: Readonly> = { - claude: 'CLAUDE_CONFIG_DIR', - codex: 'CODEX_HOME' -} - -const KNOWN_CONFIG_DIRECTORY_VARIABLES: ReadonlySet = new Set( - Object.values(CONFIG_DIRECTORY_VARIABLES) -) - -/** The variable an account home of this agent pins. */ -export function agentConfigDirectoryVariable(agent: AgentSessionHandleProvider): string { - return CONFIG_DIRECTORY_VARIABLES[agent] -} - /** The account home of `agent` at `path`. */ export function agentSessionAccountHome( - agent: AgentSessionHandleProvider, + agent: Pick, path: string ): AgentSessionAccountHome { - return { variable: agentConfigDirectoryVariable(agent), path } -} - -/** Admits only a variable some agent declares: a record's variable becomes a child's environment. */ -export function isAgentConfigDirectoryVariable(value: unknown): value is string { - return typeof value === 'string' && KNOWN_CONFIG_DIRECTORY_VARIABLES.has(value) + return { variable: agent.accountHomeVariable, path } } diff --git a/src/shared/agent-session-provider-handle-encoding.ts b/src/shared/agent-session-provider-handle-encoding.ts index da799fa26798..9cb42628f7dc 100644 --- a/src/shared/agent-session-provider-handle-encoding.ts +++ b/src/shared/agent-session-provider-handle-encoding.ts @@ -10,7 +10,6 @@ */ import type { - AgentSessionHandleProvider, AgentSessionProviderHandle, AgentSessionProviderHandleNamespace } from './agent-session-provider-handle' @@ -29,13 +28,13 @@ export const CODEX_STRUCTURED_HANDLE_NAMESPACE = { agent: 'codex' } as const satisfies AgentSessionProviderHandleNamespace -/** The namespace a record's handles live in, from the structured lane it was created on. */ -export function agentSessionProviderHandleNamespace( - provider: AgentSessionHandleProvider -): AgentSessionProviderHandleNamespace { - return provider === 'claude' +/** The typed lanes' namespaces: their handles can only ever be stored in these. */ +function builtInHandleNamespace(agent: string): AgentSessionProviderHandleNamespace | null { + return agent === CLAUDE_STRUCTURED_HANDLE_NAMESPACE.agent ? CLAUDE_STRUCTURED_HANDLE_NAMESPACE - : CODEX_STRUCTURED_HANDLE_NAMESPACE + : agent === CODEX_STRUCTURED_HANDLE_NAMESPACE.agent + ? CODEX_STRUCTURED_HANDLE_NAMESPACE + : null } export function isAgentSessionProviderHandleInNamespace( @@ -45,15 +44,18 @@ export function isAgentSessionProviderHandleInNamespace( return handle.transport === namespace.transport && handle.agent === namespace.agent } -/** Whether a handle belongs to a record of this lane. Compares namespaces; never reads its data. */ +/** + * Whether a handle belongs to a record of this agent. Compares namespaces; never reads its data. + * Claude and Codex handles are pinned to their typed lane's transport. Another agent's transport is + * its registered definition's, which the record store checks on every row it loads or writes; a + * chain never changes transport after its first link. + */ export function agentSessionProviderHandleBelongsTo( handle: AgentSessionProviderHandle, - provider: AgentSessionHandleProvider + agent: string ): boolean { - return isAgentSessionProviderHandleInNamespace( - handle, - agentSessionProviderHandleNamespace(provider) - ) + const builtIn = builtInHandleNamespace(agent) + return builtIn ? isAgentSessionProviderHandleInNamespace(handle, builtIn) : handle.agent === agent } /** Claude's provider data is its resume leaf: the transcript entry a resume continues from. */ @@ -97,6 +99,12 @@ function isNamespacePart(value: unknown): value is string { return typeof value === 'string' && NAMESPACE_PART_PATTERN.test(value) } +/** An agent id a host may register: the same bounded slug a handle's agent is. Whether a host runs + * it is that host's registry's answer, not this check's. */ +export function isStructuredAgentId(value: unknown): value is string { + return isNamespacePart(value) +} + function isLegacyNamespace(handle: AgentSessionProviderHandleNamespace): boolean { return ( isAgentSessionProviderHandleInNamespace(handle, CLAUDE_STRUCTURED_HANDLE_NAMESPACE) || diff --git a/src/shared/agent-session-provider-handle.ts b/src/shared/agent-session-provider-handle.ts index c16c87e1490a..ee94d26b3c8d 100644 --- a/src/shared/agent-session-provider-handle.ts +++ b/src/shared/agent-session-provider-handle.ts @@ -27,14 +27,23 @@ import { export { agentSessionProviderHandleKey, agentSessionProviderHandleRoot, - isAgentSessionProviderHandle + isAgentSessionProviderHandle, + isStructuredAgentId } from './agent-session-provider-handle-encoding' -/** The structured lanes this build drives. A record names one; a handle never does. */ +/** + * The structured agents every build ships. They are not the whole set: a host may register more, + * advertises that it does with the registered-agents capability, and lists them through + * `agentSession.agents`. A client that has not learned a host's list knows only these. + */ export const AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS = ['claude', 'codex'] as const export type AgentSessionHandleProvider = (typeof AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS)[number] +/** A structured agent a host registered: one of the built-ins or any agent id the host lists. + * Checked as a slug by `isStructuredAgentId`; whether a host runs it is its registry's answer. */ +export type StructuredAgentId = string + /** Runtime guard for persisted/remote provider metadata. Unknown values must not impersonate Codex. */ export function isAgentSessionHandleProvider(value: unknown): value is AgentSessionHandleProvider { return value === 'claude' || value === 'codex' diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 5022b8f0c0b5..acf6ddc308e3 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -22,14 +22,14 @@ import { } from './agent-session-conversation-command' import { decodePersistedAgentSessionProviderHandleChain, - type AgentSessionHandleProvider, type AgentSessionProviderHandleLink } from './agent-session-provider-handle' -import { agentSessionProviderHandleBelongsTo } from './agent-session-provider-handle-encoding' +import { isAgentSessionProviderHandleInNamespace } from './agent-session-provider-handle-encoding' +import type { AgentSessionAccountHome } from './agent-session-account-home' import { - isAgentConfigDirectoryVariable, - type AgentSessionAccountHome -} from './agent-session-account-home' + isDeclaredAccountHomeVariable, + type AgentSessionStoredAgents +} from './agent-session-stored-agent' export type { AgentSessionAccountHome } from './agent-session-account-home' @@ -134,7 +134,8 @@ export type AgentSessionRecord = { schemaVersion: typeof AGENT_SESSION_RECORD_SCHEMA_VERSION sessionId: string location: AgentSessionExecutionLocation - provider: AgentSessionHandleProvider + /** The registered agent this session runs; its definition decides what the record may store. */ + provider: string providerHandleChain: AgentSessionProviderHandleLink[] accountHome: AgentSessionAccountHome /** Provider options the user chose, replayed whenever a new owner starts the session. */ @@ -226,13 +227,17 @@ export function isAgentSessionProcessIdentity( ) } -function isAgentSessionAccountHome(value: unknown): value is AgentSessionAccountHome { +function isAgentSessionAccountHome( + value: unknown, + agents: AgentSessionStoredAgents +): value is AgentSessionAccountHome { if (typeof value !== 'object' || value === null) { return false } const home = value as Partial return ( - isAgentConfigDirectoryVariable(home.variable) && isBoundedString(home.path, MAX_PATH_LENGTH) + isDeclaredAccountHomeVariable(agents, home.variable) && + isBoundedString(home.path, MAX_PATH_LENGTH) ) } @@ -333,20 +338,23 @@ function isPersistedAgentSessionLease(value: unknown): value is PersistedAgentSe } /** The on-disk shape, which still admits the removed terminal handoff's lease values and stores - * handles in their typed form. Decode through `decodePersistedAgentSessionRecord` before use. */ + * handles in their typed form. Decode through `decodePersistedAgentSessionRecord` before use. + * `agents` are the host's registered agents: a record of any other agent is not readable here. */ export function isPersistedAgentSessionRecord( - value: unknown + value: unknown, + agents: AgentSessionStoredAgents ): value is PersistedAgentSessionRecord { if (typeof value !== 'object' || value === null) { return false } const record = value as Partial + const agent = typeof record.provider === 'string' ? agents.get(record.provider) : undefined const fieldsValid = record.schemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION && isAgentSessionId(record.sessionId) && isAgentSessionExecutionLocation(record.location) && - (record.provider === 'claude' || record.provider === 'codex') && - isAgentSessionAccountHome(record.accountHome) && + agent !== undefined && + isAgentSessionAccountHome(record.accountHome, agents) && (record.options === undefined || isAgentSessionOptions(record.options)) && (record.rewind === undefined || isAgentSessionRewindRecord(record.rewind)) && (record.conversationCommand === undefined || @@ -366,9 +374,10 @@ export function isPersistedAgentSessionRecord( // The row holds stored handles; validate the chain they decode to. const chain = decodePersistedAgentSessionProviderHandleChain(validated.providerHandleChain) const head = chain?.at(-1) + const namespace = { transport: agent.handleTransport, agent: agent.agent } return ( chain !== null && - chain.every((link) => agentSessionProviderHandleBelongsTo(link.handle, validated.provider)) && + chain.every((link) => isAgentSessionProviderHandleInNamespace(link.handle, namespace)) && (validated.lease.claimStatus !== 'live' || (validated.lease.ownerProcess !== null && head?.linkId === validated.lease.provenHandleLinkId && diff --git a/src/shared/agent-session-registered-agents.ts b/src/shared/agent-session-registered-agents.ts new file mode 100644 index 000000000000..fc4b16f90014 --- /dev/null +++ b/src/shared/agent-session-registered-agents.ts @@ -0,0 +1,73 @@ +/** + * `agentSession.agents`: the structured agents a host registered, each with the capability record + * its definition declares, so a client can show what an agent supports before any session of it + * exists. Published only by hosts advertising the registered-agents capability. + * + * A host is newer than some of its clients, so the reply is read row by row: a row this build + * cannot read is dropped and the rest are kept, and an arm this build does not know degrades to the + * one that claims the least. + */ + +import { z } from 'zod' +import type { AgentSessionCapabilities } from './agent-session-capabilities' +import { isStructuredAgentId } from './agent-session-provider-handle-encoding' +import { openEnum } from './zod-salvage' + +export const AGENT_SESSION_AGENTS_METHOD = 'agentSession.agents' + +export type AgentSessionRegisteredAgent = { + agent: string + capabilities: AgentSessionCapabilities +} + +export type AgentSessionAgentsResult = { + agents: AgentSessionRegisteredAgent[] +} + +/** Bounds one reply; no host registers anywhere near this many agents. */ +const MAX_REGISTERED_AGENTS = 128 + +// An absent flag is a capability the host did not claim. +const capabilityFlag = z.boolean().optional().default(false) + +const registeredAgentSchema = z.object({ + agent: z.string().refine(isStructuredAgentId), + capabilities: z.object({ + rewind: capabilityFlag, + compact: capabilityFlag, + threadGoal: capabilityFlag, + contextUsage: capabilityFlag, + imagePrompts: capabilityFlag, + // Holding a follow-up until the turn ends never interrupts one the client cannot reason about. + steering: openEnum(['inject', 'queue'] as const, 'queue') + .optional() + .default('queue'), + // `orca` claims only that Orca answers the agent's requests, never that it confines the agent. + approvalEnforcement: openEnum(['provider', 'orca'] as const, 'orca') + .optional() + .default('orca') + }) +}) + +/** The host's agents as this build reads them, or null when the reply is not an agent list. */ +export function decodeAgentSessionAgentsResult( + value: unknown +): AgentSessionRegisteredAgent[] | null { + if (typeof value !== 'object' || value === null || !('agents' in value)) { + return null + } + const { agents } = value + if (!Array.isArray(agents)) { + return null + } + const seen = new Set() + const decoded: AgentSessionRegisteredAgent[] = [] + for (const row of agents.slice(0, MAX_REGISTERED_AGENTS)) { + const parsed = registeredAgentSchema.safeParse(row) + if (parsed.success && !seen.has(parsed.data.agent)) { + seen.add(parsed.data.agent) + decoded.push(parsed.data) + } + } + return decoded +} diff --git a/src/shared/agent-session-stored-agent.test-fixture.ts b/src/shared/agent-session-stored-agent.test-fixture.ts new file mode 100644 index 000000000000..de441fff4ad1 --- /dev/null +++ b/src/shared/agent-session-stored-agent.test-fixture.ts @@ -0,0 +1,19 @@ +import { + CLAUDE_STRUCTURED_HANDLE_NAMESPACE, + CODEX_STRUCTURED_HANDLE_NAMESPACE +} from './agent-session-provider-handle-encoding' +import { agentSessionStoredAgents } from './agent-session-stored-agent' + +/** What the shipped Claude and Codex definitions declare; a runtime test pins them equal. */ +export const CLAUDE_AND_CODEX_STORED_AGENTS = agentSessionStoredAgents([ + { + agent: 'claude', + handleTransport: CLAUDE_STRUCTURED_HANDLE_NAMESPACE.transport, + accountHomeVariable: 'CLAUDE_CONFIG_DIR' + }, + { + agent: 'codex', + handleTransport: CODEX_STRUCTURED_HANDLE_NAMESPACE.transport, + accountHomeVariable: 'CODEX_HOME' + } +]) diff --git a/src/shared/agent-session-stored-agent.ts b/src/shared/agent-session-stored-agent.ts new file mode 100644 index 000000000000..b013e2afacb1 --- /dev/null +++ b/src/shared/agent-session-stored-agent.ts @@ -0,0 +1,50 @@ +/** + * What a record of one structured agent may store, as the agent's definition declares it. + * + * A host admits records only of agents it registered: the runtime hands its registered agents to + * the record store, and a row of any other agent is set aside unread, never rewritten. Nothing here + * names an agent; the list is the runtime's. + */ + +import type { AgentSessionProviderTransport } from './agent-session-provider-handle' + +export type AgentSessionStoredAgent = { + /** The Orca agent a record names as its `provider`. */ + agent: string + /** The protocol whose id space this agent's provider handles live in. */ + handleTransport: AgentSessionProviderTransport + /** Environment variable naming the agent's config directory, pinned as the record's account home. */ + accountHomeVariable: string +} + +/** The agents a record store admits, keyed by agent. */ +export type AgentSessionStoredAgents = ReadonlyMap + +export function agentSessionStoredAgents( + agents: readonly AgentSessionStoredAgent[] +): AgentSessionStoredAgents { + const byAgent = new Map() + for (const { agent, handleTransport, accountHomeVariable } of agents) { + if (byAgent.has(agent)) { + throw new Error(`structured agent ${agent} is registered twice`) + } + byAgent.set(agent, { agent, handleTransport, accountHomeVariable }) + } + return byAgent +} + +/** Admits only a variable a registered agent declares: a record's variable becomes a child's environment. */ +export function isDeclaredAccountHomeVariable( + agents: AgentSessionStoredAgents, + value: unknown +): value is string { + if (typeof value !== 'string') { + return false + } + for (const agent of agents.values()) { + if (agent.accountHomeVariable === value) { + return true + } + } + return false +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 47deda80d81d..bf93b95f80be 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -211,6 +211,14 @@ export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = // journal and lifecycle surfaces independently from Codex support. export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.claude.v1' as const +// Why: a host's structured agents are the ones it registered, not a list every build ships. A host +// advertising this accepts any agent it lists through `agentSession.agents` (with each agent's +// capability record) in `agentSession.*` params, and refuses one it did not register; a client +// offers an agent beyond Claude and Codex only to such a host. A client advertising it renders an +// `agent-session` tab of any agent its host lists; the host withholds every other agent's tabs from +// a client that does not (an older client would list them with an empty pane). +export const STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY = + 'agent-session.structured.registered-agents.v1' as const // Why: paired structured clients explicitly hold every visible session surface, allowing the host // to stop provider children after the last surface closes without tying lifetime to a transport. export const STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY = @@ -402,6 +410,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_COMPLETION_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index d6d81433e8e0..7276915e1d93 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -472,6 +472,7 @@ import { } from './speech-params' import { SshTarget } from './ssh-params' import { + AgentsParams, AttachParams, CancelParams, ConversationCommandParams, @@ -585,6 +586,7 @@ export const RPC_PARAMS_BY_METHOD = { 'agent.launch': AgentLaunch, 'agent.launchReplay': AgentLaunchReplay, 'agentHooks.prepareCodexForWslPane': PrepareCodexForWslPaneParams, + 'agentSession.agents': AgentsParams, 'agentSession.cancel': CancelParams, 'agentSession.close': OptionsParams, 'agentSession.commands': OptionsParams, diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index b44f72041683..860b9233dba3 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { isAgentSessionSurfaceTabId } from '../agent-session-surface-tab-id' import { isAgentSessionId } from '../agent-session-record' +import { isStructuredAgentId } from '../agent-session-provider-handle-encoding' import { normalizeExecutionHostId } from '../execution-host' import { AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES, @@ -73,6 +74,10 @@ export const ProviderHandle = z.discriminatedUnion('kind', [ .strict() ]) +/** Any agent a host may register. The host refuses one it did not register; a client sends one + * beyond Claude and Codex only to a host advertising the registered-agents capability. */ +export const StructuredAgent = z.string().refine(isStructuredAgentId, 'Invalid agent') + export const ExecutionHostId = z .string() .max(MAX_ID_LENGTH) @@ -97,6 +102,8 @@ export const AccountHome = z }) .strict() +/** Attaching by a client-supplied handle stays Claude/Codex: only their handles have a wire form, and + * every client creates other agents by intent, which the host resolves. */ export const AttachParams = z .object({ envelope: MutationEnvelope, @@ -122,7 +129,7 @@ export const CreateIntentParams = z .object({ envelope: MutationEnvelope, worktree: Identifier('Invalid worktree selector'), - agent: z.enum(['claude', 'codex']), + agent: StructuredAgent, resumeFrom: ResumeSource.optional(), /** * The tab id the client reserved for this chat, so it can place the tab before the reply. The @@ -141,7 +148,7 @@ export const CreateParams = z.union([AttachParams, CreateIntentParams]) export const CreateSupportParams = z .object({ worktree: Identifier('Invalid worktree selector'), - agent: z.enum(['claude', 'codex']) + agent: StructuredAgent }) .strict() @@ -291,11 +298,14 @@ export const SetOptionParams = z export const OptionsParams = z.object({ sessionId: SessionId }).strict() +/** `agentSession.agents` takes nothing: the list is the host's, whichever client asks. */ +export const AgentsParams = z.object({}).strict() + /** `sessionId` scopes the catalog to that session's pinned account; without a * session record the host keys it by the account a new launch would pin. * `worktree` names where a new chat runs, whose own config may replace the default. */ export const ModelCatalogParams = z.strictObject({ - agent: z.enum(['claude', 'codex']), + agent: StructuredAgent, sessionId: SessionId.optional(), worktree: Identifier('Invalid worktree selector').optional() }) diff --git a/src/shared/runtime-mobile-session-tab-contracts.ts b/src/shared/runtime-mobile-session-tab-contracts.ts index 563637c562f3..68a030dedb6a 100644 --- a/src/shared/runtime-mobile-session-tab-contracts.ts +++ b/src/shared/runtime-mobile-session-tab-contracts.ts @@ -4,6 +4,7 @@ import type { RuntimeBrowserPlacement } from './runtime-browser-placement' import type { TerminalColorOverrides } from './terminal-color-overrides' import type { TerminalLayoutSnapshot } from './terminal-tab-types' import type { TuiAgent } from './tui-agent' +import type { StructuredAgentId } from './agent-session-provider-handle' export type RuntimeMobileSessionTerminalTab = { type: 'terminal' @@ -97,7 +98,9 @@ export type RuntimeMobileSessionAgentTab = { title: string sessionId: string replacesSessionId?: string - agent: 'claude' | 'codex' + /** An agent the host registered. Beyond Claude and Codex, published only to clients advertising + * the registered-agents capability. */ + agent: StructuredAgentId color?: string | null isPinned?: boolean isActive: boolean diff --git a/src/shared/structured-agent-session-child-work-legacy.ts b/src/shared/structured-agent-session-child-work-legacy.ts index 72c0d0db94ce..52e7c1180a4f 100644 --- a/src/shared/structured-agent-session-child-work-legacy.ts +++ b/src/shared/structured-agent-session-child-work-legacy.ts @@ -11,7 +11,7 @@ // must be decided here for those older readers too. `structuredChildWorkLegacySubagents` is not wire: // it feeds in-app readers of `AgentStatusEntry.subagents`, and dies when they read `children`. -import type { AgentSessionHandleProvider } from './agent-session-provider-handle' +import type { StructuredAgentId } from './agent-session-provider-handle' import { codexChildCommandDescription } from './codex-child-command-description' import { projectAgentChildWorkLegacyBackgroundTasks, @@ -29,7 +29,7 @@ export function codexAgentBackgroundTaskId(threadId: string): string { function withLegacyIds( views: readonly AgentChildWorkView[], - provider: AgentSessionHandleProvider + provider: StructuredAgentId ): AgentChildWorkView[] { return views.map((view) => provider === 'codex' && view.kind === 'agent' && view.providerId !== undefined @@ -59,7 +59,7 @@ function withCodexChildCommands(views: readonly AgentChildWorkView[]): AgentChil export function structuredChildWorkLegacyTasks( views: readonly AgentChildWorkView[], - provider: AgentSessionHandleProvider + provider: StructuredAgentId ): AgentChildWorkLegacyBackgroundProjection { return projectAgentChildWorkLegacyBackgroundTasks( withLegacyIds(provider === 'codex' ? withCodexChildCommands(views) : views, provider) @@ -68,7 +68,7 @@ export function structuredChildWorkLegacyTasks( export function structuredChildWorkLegacySubagents( views: readonly AgentChildWorkView[], - provider: AgentSessionHandleProvider + provider: StructuredAgentId ): AgentSubagentSnapshot[] | undefined { return projectAgentChildWorkLegacySubagents(withLegacyIds(views, provider)) } diff --git a/src/shared/structured-native-chat-launch-route.ts b/src/shared/structured-native-chat-launch-route.ts index eac3a87268e3..9982a4edef6a 100644 --- a/src/shared/structured-native-chat-launch-route.ts +++ b/src/shared/structured-native-chat-launch-route.ts @@ -14,6 +14,7 @@ import type { GlobalSettings } from './global-settings-types' import type { ProjectExecutionRuntimeResolution } from './project-execution-runtime' import { STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from './protocol-version' import type { TuiAgent } from './tui-agent' @@ -60,6 +61,9 @@ export type StructuredNativeChatSupportInput = { requiresTuiLaunchCommand?: boolean /** An existing PTY agent keeps its execution transport. */ reusesTerminal?: boolean + /** The agents the host listed through `agentSession.agents`. Absent: none learned, so only + * Claude and Codex, which every structured host runs, can be offered. */ + hostStructuredAgents?: readonly string[] } /** The user's default for a new agent tab: native chat rather than the raw TUI. */ @@ -99,7 +103,8 @@ export function resolveStructuredNativeChatSupport( if (input.reusesTerminal === true) { return { supported: false, blocker: 'reused-terminal' } } - if (!isAgentSessionHandleProvider(input.agent)) { + const builtInAgent = isAgentSessionHandleProvider(input.agent) + if (!builtInAgent && !input.hostStructuredAgents?.includes(input.agent)) { return { supported: false, blocker: 'agent-without-structured-session' } } if (input.workspaceKind === 'floating') { @@ -115,7 +120,14 @@ export function resolveStructuredNativeChatSupport( if (input.hostCapabilities === null) { return { supported: false, blocker: 'runtime-capability-unknown' } } - if (!input.hostCapabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) { + if ( + !input.hostCapabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) || + // A host that does not advertise its registered agents accepts only Claude and Codex. + (!builtInAgent && + !input.hostCapabilities.includes( + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + )) + ) { return { supported: false, blocker: 'runtime-capability' } } if (host.kind === 'runtime') { diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 54448e1e5478..9f983c832820 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -38,6 +38,7 @@ import { workspaceVisibleTabTypeSchema } from './workspace-session-tab-type-schema' import { salvagedField, salvagedOptional, salvagingArray, salvagingRecord } from './zod-salvage' +import { isStructuredAgentId } from './agent-session-provider-handle-encoding' // ─── Terminal pane layout (recursive) ─────────────────────────────── @@ -133,7 +134,14 @@ const tabSchema = z.object({ worktreeId: z.string(), executionHostId: executionHostIdSchema.optional(), contentType: tabContentTypeSchema, - agentSessionAgent: z.enum(['codex', 'claude']).optional().catch(undefined), + // Why: any agent a host registered, as the host published it. An id that is not an agent slug + // degrades to absent, which renders no chat, rather than failing the whole-session parse; a + // build that predates an agent reads its tab the same way. + agentSessionAgent: z + .string() + .refine((value) => isStructuredAgentId(value)) + .optional() + .catch(undefined), label: z.string(), generatedLabel: z.string().nullable().optional(), aiVaultTitle: z From 620b8d7deb28d287d327de731468e2446c0d60c4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:19:08 -0700 Subject: [PATCH 038/269] Spell the subagent thread's message slot without spreading an identity union --- .../agent-session-timeline/provider-timeline-joins.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts index 8cddc98a2278..6a3f1e8d9d3f 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts @@ -62,9 +62,9 @@ const inTurn = (turn: string) => ({ scope: { kind: 'turn', turnItemId: `turn:${turn}` } as const }) -const codexSlot = (turn: string, ordinal: number): AgentJournalItemIdentity => ({ +const codexSlot = (turn: string, ordinal: number, threadId = 'root'): AgentJournalItemIdentity => ({ provider: 'codex', - threadId: 'root', + threadId, turnId: turn, ordinal }) @@ -130,7 +130,7 @@ describe('provider timeline joins', () => { await write(journal, { identity: codexSlot('t1', 2), ref: 'item:old' }, 'old') // Another turn and another thread keep their own places. await write(journal, { identity: codexSlot('t2', 7) }, 'other turn') - await write(journal, { identity: { ...codexSlot('t1', 9), threadId: 'sub' } }, 'subagent') + await write(journal, { identity: codexSlot('t1', 9, 'sub') }, 'subagent') const joins = codexJoins() expect(joins.place(message('new'), 'message', inTurn('t1'), journal).itemId).toBe( 'codex:root:t1:3' From e892640d72c612fec4866c73813a39f85005b980 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:52:59 -0700 Subject: [PATCH 039/269] Add a provider timeline grammar and a shared assembler that decides at its turn in the journal Adapters translate their provider's dialect into a small grammar (turns, items, streamed text, requests, context facts, session end/reset); one shared assembler turns it into the journal rows every structured lane writes. Each event is planned as one sink transition. Which row a write lands on, whether a replay writes anything, and every change to what the assembler knows (its ledger) are decided by the transition's resolvers at the event's turn in the journal's write queue, against the fold as it stands then. A forecast (the ledger plus admitted events still queued) only answers apply() at once. So a refused event allocates nothing, a write the journal rejects leaves no trace in memory, and a restart or evicted cache finds the same rows again. Text and full snapshots of one provider item share one row and one lifecycle; reset always flushes text and settles the old session from the journal; the open-work budget is derived from what is actually open. Codex migration contracts compare against the existing Codex translator, including a restart mid-stream and a repeat that outlives the join cache. --- .../codex-provider-timeline-identity.test.ts | 409 ++++++++++++++++++ .../provider-timeline-apply-host.ts | 35 ++ .../provider-timeline-assembler-items.test.ts | 255 +++++++++++ .../provider-timeline-assembler-order.test.ts | 254 +++++++++++ ...ovider-timeline-assembler-recovery.test.ts | 402 +++++++++++++++++ ...rovider-timeline-assembler-refusal.test.ts | 208 +++++++++ ...provider-timeline-assembler-replay.test.ts | 295 +++++++++++++ ...rovider-timeline-assembler-session.test.ts | 196 +++++++++ ...rovider-timeline-assembler-test-support.ts | 201 +++++++++ .../provider-timeline-assembler-turns.test.ts | 183 ++++++++ .../provider-timeline-assembler.ts | 250 +++++++++++ .../provider-timeline-budget.ts | 66 +++ .../provider-timeline-context.ts | 67 +++ .../provider-timeline-decision.ts | 102 +++++ .../provider-timeline-decisions.ts | 76 ++++ .../provider-timeline-event.ts | 128 ++++++ .../provider-timeline-item-decisions.ts | 196 +++++++++ .../provider-timeline-plan.ts | 144 ++++++ .../provider-timeline-settlement.ts | 147 +++++++ .../provider-timeline-state.ts | 172 ++++++++ .../provider-timeline-text-events.ts | 105 +++++ .../provider-timeline-text-streams.ts | 360 +++++++++++++++ .../provider-timeline-transition-layout.ts | 120 +++++ .../provider-timeline-turn-decisions.ts | 235 ++++++++++ 24 files changed, 4606 insertions(+) create mode 100644 src/main/codex/codex-provider-timeline-identity.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-context.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-event.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-state.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts diff --git a/src/main/codex/codex-provider-timeline-identity.test.ts b/src/main/codex/codex-provider-timeline-identity.test.ts new file mode 100644 index 000000000000..84fc68f15bf3 --- /dev/null +++ b/src/main/codex/codex-provider-timeline-identity.test.ts @@ -0,0 +1,409 @@ +// The migration contract for moving Codex onto the shared timeline assembler: the assembler with +// the Codex identity scheme lands the same rows Codex's own translator lands today — the same keys +// (live messages, history restored on resume, a late item naming its own turn, interleaved +// subagent threads) and the same row contents. + +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { ProviderTurnMessageOrdinals } from '../native-chat/agent-session-timeline/provider-turn-message-ordinals' +import { createCodexProviderTimelineIdentityScheme } from './codex-provider-timeline-identity' +import { + codexItemIdentity, + codexJournalItem, + type CodexThreadItem +} from './codex-structured-item-translation' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' + +afterEach(closeProviderTimelineRigs) + +const THREAD = 'thread-abc' +const CHILD = 'thread-child' + +async function codexRig() { + return openProviderTimelineRig({ + scheme: createCodexProviderTimelineIdentityScheme({ + sessionId: SESSION, + primaryThreadId: () => THREAD + }), + ownThread: () => THREAD + }) +} + +function body(item: CodexThreadItem, started?: CodexThreadItem) { + const translated = codexJournalItem(item, undefined, started).body + if ( + !translated || + translated.kind === 'turn' || + translated.kind === 'approval' || + translated.kind === 'question' + ) { + throw new Error(`no item body for ${item.type}`) + } + return translated +} + +const keys = (rows: AgentJournalRenderItem[]) => + rows.filter((row) => row.body.kind !== 'turn').map((row) => row.itemId) + +/** What a client renders from a row; sequence, revision and receipt time are the journal's own. */ +const content = (rows: AgentJournalRenderItem[]) => + JSON.stringify( + rows.map( + ({ + itemId, + body, + turnScope, + agentId, + parentAgentId, + providerParentRef, + producerKind, + attempt + }) => ({ + itemId, + body, + turnScope, + agentId, + parentAgentId, + providerParentRef, + producerKind, + attempt + }) + ) + ) + +function liveTranslator(rig: Awaited>) { + return createCodexJournalTranslator({ + sink: rig.eventSink, + sessionId: SESSION, + primaryThreadId: () => THREAD, + now: () => 1_000, + schedule: (run) => { + run() + return () => {} + } + }) +} + +function notify(method: string, params: unknown): CodexStructuredSessionEvent { + return { type: 'notification', sessionId: SESSION, threadId: THREAD, method, params } +} + +describe('Codex identities through the shared assembler', () => { + it('keys messages by turn and message ordinal, and a message outside any turn by its item id', async () => { + const rig = await codexRig() + const message = ( + id: string, + text: string, + join?: { turn?: string } + ): ProviderTimelineEvent => ({ + type: 'item.close', + item: id, + body: body({ type: 'agentMessage', id, text }), + join: { thread: THREAD, ...join } + }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply(message('item-0', 'one')) + rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 2_000, state: 'completed' }) + rig.assembler.apply(message('item-1', 'orphan')) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-2', at: 3_000 }) + rig.assembler.apply(message('item-2', 'two')) + // A message naming another turn joins that turn, whatever is open. + rig.assembler.apply(message('item-3', 'late', { turn: 'turn-9' })) + + // The keys Codex's own translator writes for the same frames. + expect(keys(await rig.rows())).toEqual([ + 'codex:thread-abc:turn-1:0', + 'orca:codex-item%3Athread-abc%3Aitem-1', + 'codex:thread-abc:turn-2:0', + 'codex:thread-abc:turn-9:0' + ]) + }) + + it('keeps interleaved subagent threads apart while their rows join the open turn', async () => { + const rig = await codexRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.close', + item: 'item-0', + body: body({ type: 'agentMessage', id: 'item-0', text: 'root' }), + join: { thread: THREAD } + }) + rig.assembler.apply({ + type: 'item.close', + item: 'item-0', + body: body({ type: 'agentMessage', id: 'item-0', text: 'child' }), + join: { thread: CHILD, turn: 'turn-child' } + }) + rig.assembler.apply({ + type: 'item.close', + item: 'item-1', + body: body({ type: 'agentMessage', id: 'item-1', text: 'still root' }), + join: { thread: THREAD } + }) + const rows = (await rig.rows()).filter((row) => row.body.kind !== 'turn') + expect(rows.map((row) => row.itemId)).toEqual([ + 'codex:thread-abc:turn-1:0', + 'codex:thread-child:turn-child:0', + 'codex:thread-abc:turn-1:1' + ]) + expect(new Set(rows.map((row) => JSON.stringify(row.turnScope))).size).toBe(1) + }) + + it('counts an echoed send in its turn’s message ordinals, as resume history does', async () => { + const rig = await codexRig() + const ordinals = new ProviderTurnMessageOrdinals() + const oracle = (item: CodexThreadItem) => + agentJournalItemKey(codexItemIdentity({ threadId: THREAD, turnId: 'turn-1', item, ordinals })) + const echo: CodexThreadItem = { type: 'userMessage', id: 'item-0', content: [] } + const reasoning: CodexThreadItem = { type: 'reasoning', id: 'item-1', summary: ['why'] } + const answer: CodexThreadItem = { type: 'agentMessage', id: 'item-2', text: 'answer' } + const expected = [oracle(echo), oracle(reasoning), oracle(answer)] + + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'input.accepted', + clientMessageId: 'send-1', + requestedAt: 900, + join: { thread: THREAD, turn: 'turn-1', item: 'item-0' } + }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'item-1' }, + channel: 'reasoning', + text: 'why', + join: { thread: THREAD } + }) + rig.assembler.apply({ + type: 'item.close', + item: 'item-2', + body: body(answer), + join: { thread: THREAD } + }) + rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 2_000, state: 'completed' }) + + expect(keys(await rig.rows())).toEqual(expected.slice(1)) + expect(expected[2]).toBe('codex:thread-abc:turn-1:1') + }) +}) + +describe('Codex rows through the shared assembler', () => { + it('lands the same rows as the Codex translator for a live turn', async () => { + const codex = await openProviderTimelineRig() + let clock = 1_000 + const translator = createCodexJournalTranslator({ + sink: codex.eventSink, + sessionId: SESSION, + primaryThreadId: () => THREAD, + now: () => clock, + schedule: (run) => { + run() + return () => {} + } + }) + const notify = (method: string, params: unknown): CodexStructuredSessionEvent => ({ + type: 'notification', + sessionId: SESSION, + threadId: THREAD, + method, + params + }) + const started: CodexThreadItem = { + type: 'commandExecution', + id: 'exec-1', + command: 'ls', + status: 'inProgress' + } + const finished: CodexThreadItem = { + ...started, + status: 'completed', + exitCode: 0, + aggregatedOutput: 'ok' + } + const reply: CodexThreadItem = { type: 'agentMessage', id: 'item-1', text: 'Hello' } + translator.handle(notify('turn/started', { turn: { id: 'turn-1' } })) + translator.handle(notify('item/started', { item: { ...reply, text: '' } })) + translator.handle(notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Hello' })) + translator.handle(notify('item/completed', { item: reply })) + translator.handle(notify('item/started', { item: started })) + translator.handle(notify('item/completed', { item: finished })) + clock = 2_000 + translator.handle( + notify('turn/completed', { turn: { id: 'turn-1', status: 'completed', durationMs: 900 } }) + ) + + const shared = await codexRig() + const join = { thread: THREAD, turn: 'turn-1' } + shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + shared.assembler.apply({ + type: 'text.delta', + item: { id: 'item-1' }, + channel: 'assistant', + text: 'Hello', + join + }) + shared.assembler.apply({ type: 'item.close', item: 'item-1', body: body(reply), join }) + shared.assembler.apply({ type: 'item.open', item: 'exec-1', body: body(started), join }) + shared.assembler.apply({ + type: 'item.close', + item: 'exec-1', + body: body(finished, started), + join + }) + shared.assembler.apply({ + type: 'turn.end', + turn: 'turn-1', + at: 2_000, + state: 'completed', + outcome: 'success', + durationMs: 900 + }) + + expect(content(await shared.rows())).toBe(content(await codex.rows())) + }) + + it('lands the same rows as the Codex translator for a turn restored from history', async () => { + const turn = { + id: 'turn-1', + status: 'completed', + startedAt: 1_700_000_000, + completedAt: 1_700_000_050, + items: [ + { type: 'userMessage', id: 'user-1', content: [{ type: 'text', text: 'ask' }] }, + { type: 'reasoning', id: 'think-1', summary: ['why'] }, + { type: 'agentMessage', id: 'interim-1', text: 'looking' }, + { type: 'commandExecution', id: 'exec-1', command: 'ls', status: 'completed', exitCode: 0 }, + { type: 'agentMessage', id: 'answer-1', text: 'answer' } + ] + } + const codex = await openProviderTimelineRig() + const translator = createCodexJournalTranslator({ + sink: codex.eventSink, + sessionId: SESSION, + primaryThreadId: () => THREAD + }) + expect(translator.restoreThread(THREAD, { turns: [turn] })).toEqual({ accepted: true }) + + const shared = await codexRig() + const join = { thread: THREAD, turn: 'turn-1' } + shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: turn.startedAt * 1000 }) + for (const item of turn.items) { + const translated = codexJournalItem(item).body + if ( + translated && + translated.kind !== 'turn' && + translated.kind !== 'approval' && + translated.kind !== 'question' + ) { + shared.assembler.apply({ type: 'item.close', item: item.id, body: translated, join }) + } + } + shared.assembler.apply({ + type: 'turn.end', + turn: 'turn-1', + at: turn.completedAt * 1000, + state: 'completed', + outcome: 'success' + }) + + expect(content(await shared.rows())).toBe(content(await codex.rows())) + }) + + it('lands the same rows across a restart in the middle of a streamed message', async () => { + const codex = await openProviderTimelineRig() + const translator = liveTranslator(codex) + const first: CodexThreadItem = { type: 'agentMessage', id: 'item-1', text: 'First' } + const second: CodexThreadItem = { type: 'agentMessage', id: 'item-2', text: 'Second' } + translator.handle(notify('turn/started', { turn: { id: 'turn-1' } })) + translator.handle(notify('item/completed', { item: first })) + translator.handle(notify('item/started', { item: { ...second, text: '' } })) + translator.handle(notify('item/agentMessage/delta', { itemId: 'item-2', delta: 'Sec' })) + translator.handle(notify('item/agentMessage/delta', { itemId: 'item-2', delta: 'ond' })) + translator.handle(notify('item/completed', { item: second })) + + const shared = await codexRig() + const join = { thread: THREAD, turn: 'turn-1' } + shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + shared.assembler.apply({ type: 'item.close', item: 'item-1', body: body(first), join }) + shared.assembler.apply({ + type: 'text.delta', + item: { id: 'item-2' }, + channel: 'assistant', + text: 'Sec', + join + }) + await shared.rows() + // The host restarts while Codex is still streaming item-2. + const restarted = shared.restart({ generation: 'gen-2' }) + restarted.apply({ + type: 'text.delta', + item: { id: 'item-2' }, + channel: 'assistant', + text: 'ond', + join + }) + restarted.apply({ type: 'item.close', item: 'item-2', body: body(second), join }) + + const rows = await shared.rows() + expect(content(rows)).toBe(content(await codex.rows())) + // Codex message rows carry the item id their ordinal identity cannot spell. + expect( + rows.filter((row) => row.body.kind === 'message').map((row) => row.providerItemRef) + ).toEqual([expect.stringContaining('item-1'), expect.stringContaining('item-2')]) + }) + + it('lands the same rows, and no second copy, when a late repeat outlives the join cache', async () => { + const codex = await openProviderTimelineRig() + const translator = liveTranslator(codex) + const shared = await codexRig() + const old: CodexThreadItem = { type: 'agentMessage', id: 'old-1', text: 'Old' } + translator.handle(notify('turn/started', { turn: { id: 'turn-1' } })) + translator.handle(notify('item/completed', { item: old })) + translator.handle(notify('turn/completed', { turn: { id: 'turn-1', status: 'completed' } })) + translator.handle(notify('turn/started', { turn: { id: 'turn-2' } })) + shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + shared.assembler.apply({ + type: 'item.close', + item: 'old-1', + body: body(old), + join: { thread: THREAD } + }) + shared.assembler.apply({ + type: 'turn.end', + turn: 'turn-1', + at: 1_000, + state: 'completed', + outcome: 'success' + }) + shared.assembler.apply({ type: 'turn.open', turn: 'turn-2', at: 1_000 }) + for (let index = 0; index < 1_025; index += 1) { + const item: CodexThreadItem = { type: 'agentMessage', id: `item-${index}`, text: 'small' } + translator.handle(notify('item/completed', { item })) + shared.assembler.apply({ + type: 'item.close', + item: item.id, + body: body(item), + join: { thread: THREAD } + }) + if (index % 32 === 31) { + await Promise.all([codex.rows(), shared.rows()]) + } + } + // Long after its join left memory, Codex repeats the old message with no turn of its own. + shared.assembler.apply({ + type: 'item.close', + item: 'old-1', + body: body(old), + join: { thread: THREAD } + }) + + expect(content(await shared.rows())).toBe(content(await codex.rows())) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts new file mode 100644 index 000000000000..12804f3150b3 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts @@ -0,0 +1,35 @@ +// What the event appliers share with the assembler that owns them. + +import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { ProviderTimelineHold } from './provider-timeline-budget' +import type { ProviderTimelineContext } from './provider-timeline-context' +import type { ProviderTimelineDropRule } from './provider-timeline-decisions' +import type { ProviderTimelinePlan } from './provider-timeline-plan' +import type { ProviderTimelineState } from './provider-timeline-state' +import type { ProviderTimelineTextStreams } from './provider-timeline-text-streams' + +export type ProviderTimelineApplyResult = { + /** The sink's answer for the event's writes; refused means nothing changed. */ + admission: StructuredAgentSessionSinkAdmission + dropped?: ProviderTimelineDropRule +} + +/** An admitted transition that has not landed: what it changes in the forecast until it does. */ +export type ProviderTimelineInFlight = { + executed: boolean + commit: ((state: ProviderTimelineState) => void) | undefined +} + +export type ProviderTimelineApplyHost = { + context: ProviderTimelineContext + streams: ProviderTimelineTextStreams + /** The forecast now; it is replaced as transitions land. */ + forecast(): ProviderTimelineState + journal(): StructuredAgentSessionTransitionJournal | null + admits(hold: ProviderTimelineHold): boolean + submit( + plan: ProviderTimelinePlan, + entry: ProviderTimelineInFlight + ): StructuredAgentSessionSinkAdmission +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts new file mode 100644 index 000000000000..0172719fef48 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts @@ -0,0 +1,255 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalPlainStatusItem +} from '../../../shared/agent-session-journal-types' +import { + assistantText, + closeProviderTimelineRigs, + openProviderTimelineRig, + providerItemId, + providerTurnItemId, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const plan = (text: string): AgentJournalPlainStatusItem => ({ + kind: 'status', + text, + presentation: 'plan-document' +}) + +function messageText(body: AgentJournalItemBody | undefined): string | undefined { + return body?.kind === 'message' && body.blocks[0]?.type === 'text' + ? body.blocks[0].text + : undefined +} + +describe('provider timeline items', () => { + it('scopes interleaved tool calls and text to the turn they ran in, in arrival order', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Let me ' + }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'look.' + }) + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) + rig.assembler.apply({ type: 'item.open', item: 'call-b', body: runningTool('grep') }) + rig.assembler.apply({ + type: 'item.close', + item: 'call-b', + body: { ...runningTool('grep'), state: 'completed' } + }) + rig.assembler.apply({ + type: 'item.close', + item: 'call-a', + body: { ...runningTool('read'), state: 'completed' } + }) + // The tool call ended the anonymous stream, so this text is a new message. + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Done.' + }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + + const rows = (await rig.rows()).filter((row) => row.body.kind !== 'turn') + expect(rows.map((row) => messageText(row.body) ?? row.itemId)).toEqual([ + 'Let me look.', + providerItemId('item', 'call-a'), + providerItemId('item', 'call-b'), + 'Done.' + ]) + const turnScope = { kind: 'turn', turnItemId: providerTurnItemId('turn-1') } + expect(rows.every((row) => JSON.stringify(row.turnScope) === JSON.stringify(turnScope))).toBe( + true + ) + expect(rows.filter((row) => row.body.kind === 'tool-call').map((row) => row.body)).toEqual([ + expect.objectContaining({ name: 'read', state: 'completed' }), + expect.objectContaining({ name: 'grep', state: 'completed' }) + ]) + }) + + it('drops a second close of a settled item and keeps the first terminal body', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.close', + item: 'call-a', + body: { ...runningTool('read'), state: 'completed' } + }) + const repeat = rig.assembler.apply({ + type: 'item.close', + item: 'call-a', + body: { ...runningTool('read'), state: 'failed' } + }) + expect(repeat.dropped).toBe('item-settled') + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'completed' + }) + }) + + it('reopens a settled item under the same row', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'item.close', item: 'agent-1', body: runningTool('task') }) + rig.assembler.apply({ type: 'item.open', item: 'agent-1', body: runningTool('task') }) + rig.assembler.apply({ + type: 'item.close', + item: 'agent-1', + body: { ...runningTool('task'), state: 'completed' } + }) + const rows = (await rig.rows()).filter((row) => row.body.kind === 'tool-call') + expect(rows).toHaveLength(1) + expect(rows[0]?.body).toMatchObject({ state: 'completed' }) + }) + + it('replaces a plan in place with each whole-list update', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'item.update', item: 'plan', body: plan('1. read') }) + rig.assembler.apply({ type: 'item.update', item: 'plan', body: plan('1. read ✓\n2. edit') }) + const rows = (await rig.rows()).filter((row) => row.body.kind === 'status') + expect(rows).toHaveLength(1) + expect(rows[0]?.body).toMatchObject({ text: '1. read ✓\n2. edit' }) + }) + + it('writes an item that arrives with no turn open as a thread row, opening no turn', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'item.close', item: 'call-a', body: assistantText('stray') }) + expect(rig.assembler.openTurnId).toBeNull() + const rows = await rig.rows() + expect(rows).toHaveLength(1) + expect(rows[0]?.turnScope).toEqual({ kind: 'thread' }) + }) + + it('fails a tool call its turn left running, and keeps one that outlives the turn open', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) + rig.assembler.apply({ + type: 'item.open', + item: 'bg-1', + body: runningTool('background'), + outlivesTurn: true + }) + rig.assembler.apply({ + type: 'turn.end', + at: 2_000, + state: 'interrupted', + outcome: 'cancellation' + }) + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'failed' + }) + expect((await rig.row(providerItemId('item', 'bg-1')))?.body).toMatchObject({ + state: 'running' + }) + + // It settles on its own close later, still scoped to the turn that started it. + rig.assembler.apply({ + type: 'item.close', + item: 'bg-1', + body: { ...runningTool('background'), state: 'completed' } + }) + const background = await rig.row(providerItemId('item', 'bg-1')) + expect(background?.body).toMatchObject({ state: 'completed' }) + expect(background?.turnScope).toEqual({ + kind: 'turn', + turnItemId: providerTurnItemId('turn-1') + }) + }) + + it('stamps the producing subagent on its rows', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.open', + item: 'call-a', + body: runningTool('read'), + producer: { agentId: 'helper-1' } + }) + expect((await rig.row(providerItemId('item', 'call-a')))?.agentId).toBe('helper-1') + }) +}) + +describe('provider timeline text streams', () => { + it('coalesces a burst of deltas into one snapshot write', async () => { + const runs: (() => void)[] = [] + const rig = await openProviderTimelineRig({ + schedule: (run) => { + runs.push(run) + return () => {} + } + }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + for (const text of ['a', 'b', 'c']) { + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text + }) + } + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'think' }, + channel: 'reasoning', + text: 'x' + }) + expect((await rig.rows()).filter((row) => row.body.kind === 'message')).toHaveLength(0) + runs.splice(0).forEach((run) => run()) + const [reply, single] = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messageText(reply?.body)).toBe('abc') + // Three deltas cost the writes one delta does. + expect(reply?.revision).toBe(single?.revision) + }) + + it('settles with the provider final text, and keys a named message by its id', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'msg-1' }, + channel: 'assistant', + text: 'Draf' + }) + rig.assembler.apply({ type: 'text.close', item: { id: 'msg-1' }, text: 'Final answer' }) + expect(messageText((await rig.row(providerItemId('item', 'msg-1')))?.body)).toBe('Final answer') + expect(rig.assembler.apply({ type: 'text.close', item: { id: 'msg-1' } }).dropped).toBe( + 'stream-unknown' + ) + }) + + it('writes reasoning as a reasoning message, and nothing for a whitespace-only stream', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'think' }, + channel: 'reasoning', + text: 'Hmm' + }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: ' \n' + }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => row.body)).toEqual([ + { kind: 'message', role: 'reasoning', blocks: [{ type: 'text', text: 'Hmm' }] } + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts new file mode 100644 index 000000000000..3c8ee4949fdf --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { MAX_PROVIDER_TIMELINE_OPEN_ENTRIES } from './provider-timeline-budget' +import { + assistantText, + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + providerItemId, + providerTurnItemId, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +/** A rig whose coalescing window only fires when the test says so. */ +async function heldWindowRig() { + const runs: (() => void)[] = [] + const rig = await openProviderTimelineRig({ + schedule: (run) => { + runs.push(run) + return () => {} + } + }) + return { rig, fire: () => runs.splice(0).forEach((run) => run()) } +} + +describe('one canonical row per provider item', () => { + it('writes each named message to its own row, even on one provider stream', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'message-1' }, + channel: 'assistant', + text: 'First' + }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'message-2' }, + channel: 'assistant', + text: 'Second' + }) + rig.assembler.flush() + expect(messageText((await rig.row(providerItemId('item', 'message-1')))?.body)).toBe('First') + expect(messageText((await rig.row(providerItemId('item', 'message-2')))?.body)).toBe('Second') + }) + + it('refuses a named message whose channel changes mid-stream instead of mixing them', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Reply' + }) + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'reasoning', + text: 'x' + }).dropped + ).toBe('stream-mismatch') + rig.assembler.flush() + expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Reply') + }) + + it('starts the next anonymous message when its producer changes', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'out' }, + channel: 'assistant', + text: 'Root' + }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'out' }, + channel: 'assistant', + text: 'Helper', + producer: { agentId: 'helper-1' } + }) + rig.assembler.flush() + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => [messageText(row.body), row.agentId])).toEqual([ + ['Root', undefined], + ['Helper', 'helper-1'] + ]) + }) + + it('settles a streamed message through its full snapshot as the same row', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'm1', body: assistantText('') }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Hel' + }) + rig.assembler.apply({ type: 'item.close', item: 'm1', body: assistantText('Hello, final') }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => [row.itemId, messageText(row.body)])).toEqual([ + [providerItemId('item', 'm1'), 'Hello, final'] + ]) + }) + + it('keeps every open stream’s whole text while the budget holds them, and refuses the next', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + for (let index = 0; index < MAX_PROVIDER_TIMELINE_OPEN_ENTRIES; index += 1) { + rig.assembler.apply({ + type: 'text.delta', + item: { id: `m${index}` }, + channel: 'assistant', + text: 'A' + }) + } + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'one-more' }, + channel: 'assistant', + text: 'A' + }).admission + ).toEqual({ accepted: false, reason: 'failed' }) + rig.assembler.apply({ type: 'text.delta', item: { id: 'm0' }, channel: 'assistant', text: 'B' }) + rig.assembler.flush() + expect(messageText((await rig.row(providerItemId('item', 'm0')))?.body)).toBe('AB') + }) +}) + +describe('every other event is an ordering barrier for text', () => { + it('writes text that arrived before a tool ahead of the tool, named stream or not', async () => { + const { rig, fire } = await heldWindowRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'First' + }) + rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + fire() + rig.assembler.flush() + const items = (await rig.rows()).filter((row) => row.body.kind !== 'turn') + expect(items.map((row) => row.itemId)).toEqual([ + providerItemId('item', 'm1'), + providerItemId('item', 'tool') + ]) + }) + + it('starts a new anonymous message after a context report, as after any row', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 's1' }, + channel: 'assistant', + text: 'First' + }) + rig.assembler.apply({ + type: 'context.usage', + usage: { window: { tokens: 1_000, capturedAt: 1_100 } } + }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 's1' }, + channel: 'assistant', + text: 'Second' + }) + rig.assembler.flush() + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => messageText(row.body))).toEqual(['First', 'Second']) + }) + + it('does not split a message on an event it dropped or on the activity line', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 's1' }, + channel: 'assistant', + text: 'One ' + }) + expect(rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }).dropped).toBe( + 'turn-duplicate' + ) + rig.assembler.apply({ type: 'activity', text: 'Thinking' }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 's1' }, + channel: 'assistant', + text: 'message' + }) + rig.assembler.flush() + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => messageText(row.body))).toEqual(['One message']) + }) +}) + +describe('explicit provider attribution', () => { + it('scopes a late item to the earlier turn the provider names', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + rig.assembler.apply({ + type: 'item.close', + item: 'late-t1-message', + body: assistantText('Late output from t1'), + join: { turn: 't1' } + }) + expect((await rig.row(providerItemId('item', 'late-t1-message')))?.turnScope).toEqual({ + kind: 'turn', + turnItemId: providerTurnItemId('t1') + }) + }) + + it('keeps an item in the turn it opened in when its close arrives after the next turn opened', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.open', + item: 'bg', + body: runningTool('task'), + outlivesTurn: true + }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + rig.assembler.apply({ + type: 'item.close', + item: 'bg', + body: { ...runningTool('task'), state: 'completed' } + }) + const row = await rig.row(providerItemId('item', 'bg')) + expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId: providerTurnItemId('t1') }) + expect(row?.body).toMatchObject({ state: 'completed' }) + }) + + it('writes context facts onto the turn the provider names, not the open one', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + rig.assembler.apply({ + type: 'context.usage', + usage: { window: { tokens: 5_000, capturedAt: 3_100 } }, + join: { turn: 't1' } + }) + expect(await rig.turn('t1')).toMatchObject({ contextUsage: { window: { tokens: 5_000 } } }) + expect(await rig.turn('t2')).not.toHaveProperty('contextUsage') + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts new file mode 100644 index 000000000000..d1bcda1b390c --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts @@ -0,0 +1,402 @@ +// Memory is a cache: what the assembler forgot (a restart, an evicted entry, a write the journal +// rejected, an event the sink refused) never changes which row an event lands on or what it says. + +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { createCodexProviderTimelineIdentityScheme } from '../../codex/codex-provider-timeline-identity' +import { createDeferredStructuredAgentSessionEventSink } from '../agent-session-wire/structured-agent-session-event-sink' +import { testEventSinkLogging } from '../agent-session-wire/structured-agent-session-logger-test-support' +import { createProviderTimelineAssembler } from './provider-timeline-assembler' +import { + AGENT, + assistantText, + closeProviderTimelineRigs, + messageText, + NAMESPACE, + openProviderTimelineRig, + pendingApproval, + providerItemId, + providerTurnId, + providerTurnItemId, + refusingSink, + runningTool, + SESSION +} from './provider-timeline-assembler-test-support' +import { providerTimelineSink } from './provider-timeline-plan' + +afterEach(closeProviderTimelineRigs) + +const codexRig = () => + openProviderTimelineRig({ + scheme: createCodexProviderTimelineIdentityScheme({ + sessionId: SESSION, + primaryThreadId: () => 'root' + }), + ownThread: () => 'root' + }) + +const messages = async (rig: Awaited>) => + (await rig.rows()).filter((row) => row.body.kind === 'message') + +describe('a forgotten join finds its original row', () => { + it('finishes an in-flight message after a restart without overwriting an earlier one', async () => { + const rig = await codexRig() + const join = { thread: 'root', turn: 't1' } + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.close', item: 'm0', body: assistantText('First'), join }) + rig.assembler.apply({ type: 'item.open', item: 'm1', body: assistantText('Partial'), join }) + await rig.rows() + + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ type: 'item.close', item: 'm1', body: assistantText('Second'), join }) + restarted.apply({ type: 'item.close', item: 'm2', body: assistantText('Third'), join }) + + expect((await messages(rig)).map((row) => [row.itemId, messageText(row.body)])).toEqual([ + ['codex:root:t1:0', 'First'], + ['codex:root:t1:1', 'Second'], + ['codex:root:t1:2', 'Third'] + ]) + }) + + it('resumes an in-flight streamed message after a restart from the text it already holds', async () => { + const rig = await codexRig() + const join = { thread: 'root', turn: 't1' } + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm0' }, + channel: 'assistant', + text: 'Hel', + join + }) + await rig.rows() + + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ + type: 'text.delta', + item: { id: 'm0' }, + channel: 'assistant', + text: 'lo', + join + }) + restarted.apply({ type: 'text.close', item: { id: 'm0' }, join }) + + expect((await messages(rig)).map((row) => [row.itemId, messageText(row.body)])).toEqual([ + ['codex:root:t1:0', 'Hello'] + ]) + }) + + it('reaches a message in its own turn after its join was evicted', async () => { + const rig = await codexRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.close', + item: 'old-message', + body: assistantText('Old'), + join: { thread: 'root' } + }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + for (let index = 0; index < 1_025; index += 1) { + rig.assembler.apply({ + type: 'item.close', + item: `m-${index}`, + body: assistantText('small'), + join: { thread: 'root' } + }) + if (index % 32 === 31) { + await rig.rows() + } + } + rig.assembler.apply({ + type: 'item.close', + item: 'old-message', + body: assistantText('Old'), + join: { thread: 'root' } + }) + + const old = (await rig.rows()).filter((row) => messageText(row.body) === 'Old') + expect(old.map((row) => row.itemId)).toEqual(['codex:root:t1:0']) + }) + + it('keeps a late tool close in the turn it opened in after its join was evicted', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.open', + item: 'background-tool', + body: runningTool('background'), + outlivesTurn: true + }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + for (let index = 0; index < 1_025; index += 1) { + rig.assembler.apply({ type: 'item.close', item: `m-${index}`, body: assistantText('small') }) + if (index % 32 === 31) { + await rig.rows() + } + } + rig.assembler.apply({ + type: 'item.close', + item: 'background-tool', + body: { ...runningTool('background'), state: 'completed' } + }) + + expect((await rig.row(providerItemId('item', 'background-tool')))?.turnScope).toEqual({ + kind: 'turn', + turnItemId: providerTurnItemId('t1') + }) + }) + + it('withdraws the request incarnation a previous run opened', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + await rig.rows() + + rig.restart({ generation: 'gen-2' }).apply({ type: 'request.withdrawn', request: 'p1' }) + + expect( + (await rig.row(providerItemId('request', 'p1', { incarnation: 2 })))?.body + ).toMatchObject({ resolution: { state: 'cancelled' } }) + }) +}) + +describe('streamed text has the same lifecycle as the item it streams into', () => { + it('keeps interleaved same-id streams on separate threads apart', async () => { + const rig = await codexRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Root', + join: { thread: 'root', turn: 't1' } + }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Child', + join: { thread: 'child', turn: 'child-turn' } + }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + + expect((await messages(rig)).map((row) => [row.itemId, messageText(row.body)])).toEqual([ + ['codex:root:t1:0', 'Root'], + ['codex:child:child-turn:0', 'Child'] + ]) + }) + + it('never lets replayed text replace a message its settled turn completed', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.close', item: 'm1', body: assistantText('Full final text') }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + await rig.rows() + + const restarted = rig.restart({ generation: 'gen-2' }) + expect( + restarted.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Full', + join: { turn: 't1' } + }).dropped + ).toBe('item-settled') + restarted.flush() + + expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Full final text') + }) + + it('settles a message on text.close, so a late delta cannot erase what it said', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Prefix' + }) + rig.assembler.apply({ type: 'text.close', item: { id: 'm1' } }) + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Suffix' + }).dropped + ).toBe('item-settled') + rig.assembler.flush() + + expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Prefix') + }) +}) + +describe('a write the journal rejects changes nothing the assembler knows', () => { + it('keeps a running tool open when a replayed supersede it planned before bind lands as nothing', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', turn: 'old', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) + await rig.rows() + + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) + const sink = providerTimelineSink(deferred.sink) + if (!sink) { + throw new Error('the deferred sink offers transitions') + } + const assembler = createProviderTimelineAssembler({ + sink, + sessionId: SESSION, + agent: AGENT, + generation: 'gen-2', + namespace: NAMESPACE + }) + // Planned before bind: nothing tells the planner the journal already holds both turns. + assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) + assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + deferred.bind({ journal: rig.journal, fence: 1, publish: () => {} }) + await deferred.drained() + expect(await rig.turn('live')).toMatchObject({ state: 'running' }) + expect(assembler.openTurnId).toBe(providerTurnId('live')) + + const closed = assembler.apply({ + type: 'item.close', + item: 'tool', + body: { ...runningTool('read'), state: 'completed' } + }) + expect(closed.dropped).toBeUndefined() + await deferred.drained() + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ + state: 'completed' + }) + deferred.close() + assembler.dispose() + }) + + it('takes no message ordinal for an event the sink refused', async () => { + const rig = await codexRig() + let refusing = false + const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) + const join = { thread: 'root', turn: 't1' } + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + refusing = true + expect( + assembler.apply({ type: 'item.close', item: 'refused', body: assistantText('No'), join }) + .admission + ).toEqual({ accepted: false, reason: 'backpressure' }) + refusing = false + assembler.apply({ type: 'item.close', item: 'accepted', body: assistantText('Yes'), join }) + + expect((await messages(rig)).map((row) => row.itemId)).toEqual(['codex:root:t1:0']) + }) +}) + +describe('reset settles the old session whatever the assembler remembers', () => { + it('writes text a reset interrupts, though nothing else was open', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Before reset' + }) + rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) + + expect((await messages(rig)).map((row) => messageText(row.body))).toEqual(['Before reset']) + }) + + it('settles the journal work of a previous run as lost', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + await rig.rows() + + rig.restart({ generation: 'gen-2' }).apply({ + type: 'session.reset', + namespace: 'provider-session-2' + }) + + expect(await rig.turn('t1')).toMatchObject({ state: 'unverifiable' }) + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ + state: 'failed' + }) + }) +}) + +describe('the budget counts the work actually open', () => { + it('frees an answered request incarnation without waiting for its turn to end', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + for (let incarnation = 1; incarnation <= 129; incarnation += 1) { + expect( + rig.assembler.apply({ type: 'request.open', request: 'approval', body: pendingApproval }) + .admission + ).toEqual({ accepted: true }) + await rig.rows() + const identity = parseAgentJournalItemKey( + providerItemId('request', 'approval', { incarnation }) + ) + if (!identity) { + throw new Error('the request has a journal key') + } + // A client answers it, through the journal. + await rig.journal.appendItem( + identity, + { + ...pendingApproval, + resolution: { + state: 'resolved', + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 1_100 + } + }, + { fence: 1, turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } } + ) + } + }) + + it('keeps a streamed message counted while its stream is open, whatever its snapshots say', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + for (let index = 0; index < 128; index += 1) { + rig.assembler.apply({ + type: 'text.delta', + item: { id: `stream-${index}` }, + channel: 'assistant', + text: 'x' + }) + rig.assembler.apply({ + type: 'item.update', + item: `stream-${index}`, + body: assistantText('x') + }) + if (index % 32 === 31) { + await rig.rows() + } + } + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'overflow' }, + channel: 'assistant', + text: 'x' + }).admission + ).toEqual({ accepted: false, reason: 'failed' }) + }) + + it('refuses a provider key larger than the whole budget', () => + openProviderTimelineRig({ schedule: () => () => {} }).then((rig) => { + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'x'.repeat(1024 * 1024 + 1) }, + channel: 'assistant', + text: 'tiny' + }).admission + ).toEqual({ accepted: false, reason: 'failed' }) + })) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts new file mode 100644 index 000000000000..4551013a3c64 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { MAX_PROVIDER_TIMELINE_OPEN_ENTRIES } from './provider-timeline-budget' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + pendingApproval, + providerItemId, + refusingSink, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const BACKPRESSURE = { accepted: false, reason: 'backpressure' } + +describe('a refused event changes nothing and its retry lands it once', () => { + it('settles the tool a refused turn end owed when the end is re-applied', async () => { + const rig = await openProviderTimelineRig() + let refusing = false + const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + refusing = true + const end = { type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' } as const + expect(assembler.apply(end).admission).toEqual(BACKPRESSURE) + expect(assembler.openTurnId).not.toBeNull() + + refusing = false + expect(assembler.apply(end)).toEqual({ admission: { accepted: true } }) + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) + expect(await rig.turn('t1')).toMatchObject({ state: 'completed', completedAt: 2_000 }) + expect(assembler.apply(end).dropped).toBe('turn-unknown') + }) + + it('keeps observed text when the close that would write it is refused', async () => { + const rig = await openProviderTimelineRig() + let refusing = false + const assembler = rig.restart({ + sink: refusingSink(rig.sink, () => refusing), + schedule: () => () => {} + }) + assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Observed text' + }) + refusing = true + expect(assembler.apply({ type: 'text.close', item: { id: 'm1' } }).admission).toEqual( + BACKPRESSURE + ) + refusing = false + assembler.flush() + expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Observed text') + }) + + it('keeps window text while the sink is full and retries it, and lets it go once the sink is gone', async () => { + const runs: (() => void)[] = [] + const rig = await openProviderTimelineRig() + let refusal: 'backpressure' | 'failed' | null = null + const assembler = rig.restart({ + sink: { + ...rig.sink, + tryAppendTransition: (transition) => + refusal ? { accepted: false, reason: refusal } : rig.sink.tryAppendTransition(transition) + }, + schedule: (run) => { + runs.push(run) + return () => {} + } + }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + refusal = 'backpressure' + assembler.apply({ type: 'text.delta', item: { id: 'm1' }, channel: 'assistant', text: 'Kept' }) + runs.splice(0).forEach((run) => run()) + // Refused under backpressure: still owed, and the window is scheduled again. + expect(runs).toHaveLength(1) + refusal = null + runs.splice(0).forEach((run) => run()) + expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Kept') + + assembler.apply({ type: 'text.delta', item: { id: 'm2' }, channel: 'assistant', text: 'Lost' }) + refusal = 'failed' + runs.splice(0).forEach((run) => run()) + // A failed sink can never take it: no retry is scheduled. + expect(runs).toHaveLength(0) + }) + + it('does not latch a refused session end, so its retry still settles the session', async () => { + const rig = await openProviderTimelineRig() + let refusing = false + const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + refusing = true + const ended = { + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 3_000 } + } as const + expect(assembler.apply(ended).admission).toEqual(BACKPRESSURE) + expect(assembler.apply({ type: 'activity', text: 'still live' }).dropped).toBeUndefined() + + refusing = false + assembler.apply(ended) + expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', completedAt: 3_000 }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + expect(assembler.apply({ type: 'turn.open', turn: 't2', at: 4_000 }).dropped).toBe( + 'session-ended' + ) + }) + + it('leaves the open turn in place when a superseding open is refused', async () => { + const rig = await openProviderTimelineRig() + let refusing = false + const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + const first = assembler.openTurnId + refusing = true + expect(assembler.apply({ type: 'turn.open', turn: 't2', at: 2_000 }).admission).toEqual( + BACKPRESSURE + ) + expect(assembler.openTurnId).toBe(first) + refusing = false + assembler.apply({ type: 'turn.open', turn: 't2', at: 2_000 }) + expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', outcome: 'superseded' }) + expect(await rig.turn('t2')).toMatchObject({ state: 'running' }) + }) + + it('keeps the old session open when the reset that would settle it is refused', async () => { + const rig = await openProviderTimelineRig() + let refusing = false + const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + refusing = true + const reset = { type: 'session.reset', namespace: 'provider-session-2' } as const + expect(assembler.apply(reset).admission).toEqual(BACKPRESSURE) + expect(assembler.openTurnId).not.toBeNull() + refusing = false + assembler.apply(reset) + expect(assembler.openTurnId).toBeNull() + expect(await rig.turn('t1')).toMatchObject({ state: 'unverifiable' }) + }) +}) + +describe('what the assembler holds open is bounded', () => { + it('refuses, as failed, work past its open budget instead of forgetting it', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + for (let index = 0; index < MAX_PROVIDER_TIMELINE_OPEN_ENTRIES / 2; index += 1) { + expect( + rig.assembler.apply({ type: 'item.open', item: `item-${index}`, body: runningTool('read') }) + .admission + ).toEqual({ accepted: true }) + expect( + rig.assembler.apply({ + type: 'request.open', + request: `request-${index}`, + body: pendingApproval + }).admission + ).toEqual({ accepted: true }) + } + const refused = { accepted: false, reason: 'failed' } + expect( + rig.assembler.apply({ type: 'item.open', item: 'one-more', body: runningTool('read') }) + .admission + ).toEqual(refused) + expect( + rig.assembler.apply({ type: 'request.open', request: 'one-more', body: pendingApproval }) + .admission + ).toEqual(refused) + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'more' }, + channel: 'assistant', + text: 'x' + }).admission + ).toEqual(refused) + expect(await rig.row(providerItemId('item', 'one-more'))).toBeUndefined() + + // Settling frees the budget again. + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + expect( + rig.assembler.apply({ type: 'item.open', item: 'one-more', body: runningTool('read') }) + .admission + ).toEqual({ accepted: true }) + }) + + it('refuses a stream past the budget, even though the coalescer would hold it', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + let admitted = 0 + for (let index = 0; index < 1_100; index += 1) { + const { admission } = rig.assembler.apply({ + type: 'text.delta', + item: { id: `m${index}` }, + channel: 'assistant', + text: 'x' + }) + admitted += admission.accepted ? 1 : 0 + } + expect(admitted).toBe(MAX_PROVIDER_TIMELINE_OPEN_ENTRIES) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts new file mode 100644 index 000000000000..bb0eee27f429 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts @@ -0,0 +1,295 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + pendingApproval, + providerItemId, + providerTurnItemId, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const answered = { + ...pendingApproval, + resolution: { + state: 'resolved' as const, + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 1_500 + } +} + +describe('a replay never changes what the journal already holds', () => { + it('keeps an answered prompt answered when a restarted host replays its open and its turn', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + const itemId = providerItemId('request', 'p1') + const identity = parseAgentJournalItemKey(itemId) + if (!identity) { + throw new Error('request key did not parse') + } + // The answer path's compare-and-set lands first, with no word to the assembler. + await rig.journal.appendItem(identity, answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + expect((await rig.row(itemId))?.body).toMatchObject({ resolution: { state: 'resolved' } }) + + const restarted = rig.restart({ generation: 'gen-2' }) + expect(restarted.apply({ type: 'turn.open', turn: 't1', at: 3_000 }).dropped).toBe( + 'turn-replayed' + ) + expect( + restarted.apply({ type: 'request.open', request: 'p1', body: pendingApproval }).dropped + ).toBe('request-replayed') + restarted.apply({ type: 'turn.end', at: 4_000, state: 'completed' }) + expect((await rig.row(itemId))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow' } + }) + }) + + it('does not let a replayed open of a settled turn supersede the live one', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + await rig.rows() + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) + restarted.apply({ type: 'item.open', item: 'live-tool', body: runningTool('read') }) + const live = restarted.openTurnId + + expect(restarted.apply({ type: 'turn.open', turn: 'old', at: 1_000 }).dropped).toBe( + 'turn-replayed' + ) + expect(restarted.openTurnId).toBe(live) + expect(await rig.turn('live')).toMatchObject({ state: 'running' }) + expect((await rig.row(providerItemId('item', 'live-tool')))?.body).toMatchObject({ + state: 'running' + }) + }) + + it('settles a turn the journal holds running even when the assembler that opened it is gone', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + await rig.rows() + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + expect(await rig.turn('t1')).toMatchObject({ state: 'completed', completedAt: 2_000 }) + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) + }) + + it('takes its open turn back from the journal when a planned open found the row already there', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'live', at: 1_000 }) + await rig.rows() + const live = rig.assembler.openTurnId + // The plan reads no journal yet (as before it binds); the write still asks it when it runs. + let lagging = true + const stale = rig.restart({ + sink: { ...rig.sink, journalItems: () => (lagging ? null : rig.sink.journalItems()) }, + generation: 'gen-2' + }) + expect(stale.apply({ type: 'turn.open', turn: 'live', at: 1_000 }).dropped).toBeUndefined() + lagging = false + await rig.rows() + await Promise.resolve() + expect(stale.openTurnId).toBe(live) + expect(await rig.turn('live')).toMatchObject({ state: 'running', startedAt: 1_000 }) + }) + + it('never resurrects a settled tool from a running snapshot', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.close', + item: 'tool', + body: { ...runningTool('read'), state: 'completed' } + }) + expect( + rig.assembler.apply({ type: 'item.update', item: 'tool', body: runningTool('read') }).dropped + ).toBe('item-settled') + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 2_000 } + }) + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ + state: 'completed' + }) + }) + + it('does not reopen a settled tool a restarted host replays', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + rig.assembler.apply({ + type: 'item.close', + item: 'tool', + body: { ...runningTool('read'), state: 'completed' } + }) + await rig.rows() + const restarted = rig.restart({ generation: 'gen-2' }) + expect( + restarted.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }).dropped + ).toBe('item-replayed') + expect( + restarted.apply({ type: 'item.update', item: 'tool', body: runningTool('read') }).dropped + ).toBe('item-settled') + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ + state: 'completed' + }) + }) +}) + +describe('requests', () => { + it('opens a request reused after it settled as a new one, and withdraws each on its own', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + await rig.rows() + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + + const prompts = (await rig.rows()).filter((row) => row.body.kind === 'approval') + expect(prompts.map((row) => row.itemId)).toEqual([ + providerItemId('request', 'p1'), + providerItemId('request', 'p1', { incarnation: 2 }) + ]) + expect(prompts.map((row) => row.body)).toEqual([ + expect.objectContaining({ resolution: expect.objectContaining({ state: 'cancelled' }) }), + expect.objectContaining({ resolution: expect.objectContaining({ state: 'cancelled' }) }) + ]) + }) + + it('opens a new request when the pending one under its key was answered', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) + if (!identity) { + throw new Error('request key did not parse') + } + await rig.journal.appendItem(identity, answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + expect( + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }).dropped + ).toBeUndefined() + // The answered prompt keeps its answer; withdrawing reaches only the new one. + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved' } + }) + expect( + (await rig.row(providerItemId('request', 'p1', { incarnation: 2 })))?.body + ).toMatchObject({ + resolution: { state: 'cancelled' } + }) + }) + + it('leaves an answered request answered when its withdrawal lands after the answer', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) + if (!identity) { + throw new Error('request key did not parse') + } + // Memory still holds it pending; only the journal knows a client answered. + await rig.journal.appendItem(identity, answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow' } + }) + }) + + it('never overwrites a request row it could not see when it planned the open', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) + if (!identity) { + throw new Error('request key did not parse') + } + await rig.journal.appendItem(identity, answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + const blind = rig.restart({ + sink: { ...rig.sink, journalItems: () => null }, + generation: 'gen-2' + }) + blind.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow' } + }) + }) +}) + +describe('a new provider session is a new id space', () => { + it('gives a reused provider turn id its own row after a reset', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: '1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 2_000 } + }) + rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) + rig.assembler.apply({ type: 'turn.open', turn: '1', at: 3_000 }) + + expect((await rig.turns()).map((turn) => turn.state)).toEqual(['completed', 'running']) + expect(await rig.turn('1', 'provider-session-2')).toMatchObject({ startedAt: 3_000 }) + }) + + it('does not overwrite an earlier session’s item when the provider reuses its id', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ + type: 'item.close', + item: 'tool-1', + body: { ...runningTool('first'), state: 'completed' } + }) + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 2_000 } + }) + rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) + rig.assembler.apply({ + type: 'item.close', + item: 'tool-1', + body: { ...runningTool('second'), state: 'completed' } + }) + const tools = (await rig.rows()).filter((row) => row.body.kind === 'tool-call') + expect(tools.map((row) => row.body)).toEqual([ + expect.objectContaining({ name: 'first' }), + expect.objectContaining({ name: 'second' }) + ]) + }) + + it('settles the open work of a session reset before anything ended it, as lost', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Half' + }) + rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) + rig.assembler.apply({ type: 'session.ended', verdict: { state: 'unverifiable' } }) + + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) + expect(await rig.turn('t1')).toMatchObject({ state: 'unverifiable' }) + expect((await rig.rows()).filter((row) => row.body.kind === 'message')).toHaveLength(1) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts new file mode 100644 index 000000000000..3bc3aeb3caa5 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts @@ -0,0 +1,196 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalApprovalItem } from '../../../shared/agent-session-journal-types' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + providerItemId, + providerTurnItemId, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const approval: AgentJournalApprovalItem = { + kind: 'approval', + title: 'Run npm test?', + detail: null, + options: [ + { id: 'allow', label: 'Allow' }, + { id: 'reject', label: 'Reject' } + ], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } +} + +describe('provider timeline session end', () => { + it('ends the open turn interrupted with no verdict when the child exit was observed', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Half' + }) + rig.assembler.apply({ type: 'request.open', request: 'perm-1', body: approval }) + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 4_000 } + }) + + const turn = await rig.turn('turn-1') + expect(turn).toMatchObject({ state: 'interrupted', completedAt: 4_000, startedAt: 1_000 }) + expect(turn).not.toHaveProperty('outcome') + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'failed' + }) + expect((await rig.row(providerItemId('request', 'perm-1')))?.body).toMatchObject({ + resolution: { state: 'cancelled', selectedOptionId: null } + }) + // A stream cut off keeps the text it received. + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => row.body)).toEqual([ + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Half' }] } + ]) + }) + + it('marks the open turn unverifiable with no end when the host lost the child', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'send-1', requestedAt: 900 }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'session.ended', verdict: { state: 'unverifiable' } }) + const turn = await rig.turn('turn-1') + expect(turn).toMatchObject({ state: 'unverifiable', startedAt: 1_000, requestedAt: 900 }) + expect(turn).not.toHaveProperty('completedAt') + expect(turn).not.toHaveProperty('durationMs') + expect(turn).not.toHaveProperty('outcome') + }) + + it('drops everything after the session ended until a reset starts a fresh namespace', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', at: 1_000 }) + const first = rig.assembler.openTurnId + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 2_000 } + }) + // A straggler from the dead child must not open a turn nothing would close. + expect(rig.assembler.apply({ type: 'turn.open', at: 2_500 }).dropped).toBe('session-ended') + expect( + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }).dropped + ).toBe('session-ended') + expect(rig.assembler.openTurnId).toBeNull() + + rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) + rig.assembler.apply({ type: 'turn.open', at: 3_000 }) + const second = rig.assembler.openTurnId + expect(second).not.toBeNull() + expect(second).not.toBe(first) + const turns = (await rig.rows()).filter((row) => row.body.kind === 'turn') + expect(turns.map((row) => row.body)).toEqual([ + expect.objectContaining({ turnId: first, state: 'interrupted' }), + expect.objectContaining({ turnId: second, state: 'running' }) + ]) + }) +}) + +describe('provider timeline requests', () => { + it('cancels a request its turn left pending', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'perm-1', body: approval }) + expect( + rig.assembler.apply({ type: 'request.open', request: 'perm-1', body: approval }).dropped + ).toBe('request-duplicate') + rig.assembler.apply({ + type: 'turn.end', + at: 2_000, + state: 'interrupted', + outcome: 'cancellation' + }) + const row = await rig.row(providerItemId('request', 'perm-1')) + expect(row?.body).toMatchObject({ resolution: { state: 'cancelled' } }) + expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId: providerTurnItemId('turn-1') }) + }) + + it('never cancels a request a client already answered', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'perm-1', body: approval }) + const itemId = providerItemId('request', 'perm-1') + const identity = parseAgentJournalItemKey(itemId) + if (!identity) { + throw new Error('request key did not parse') + } + // The answer path's compare-and-set wins on another device and writes the row resolved. + rig.journal.appendItem( + identity, + { + ...approval, + resolution: { + state: 'resolved', + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 1_500 + } + }, + { fence: 1, turnScope: { kind: 'turn', turnItemId: providerTurnItemId('turn-1') } } + ) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + expect((await rig.row(itemId))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow', resolvedBy: 'phone' } + }) + }) + + it('cancels a request the provider withdrew, once', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'perm-1', body: approval }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'perm-1' }) + expect(rig.assembler.apply({ type: 'request.withdrawn', request: 'perm-1' }).dropped).toBe( + 'request-unknown' + ) + expect((await rig.row(providerItemId('request', 'perm-1')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + }) +}) + +describe('provider timeline turn facts', () => { + it('writes context usage onto the turn that just ended, keeping its end', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + rig.assembler.apply({ + type: 'context.usage', + usage: { window: { tokens: 200_000, capturedAt: 2_100 } } + }) + expect(await rig.turn('turn-1')).toMatchObject({ + state: 'completed', + outcome: 'success', + completedAt: 2_000, + contextUsage: { window: { tokens: 200_000, capturedAt: 2_100 } } + }) + }) + + it('sets the live activity line only while a turn is open', async () => { + const rig = await openProviderTimelineRig() + expect(rig.assembler.apply({ type: 'activity', text: 'Reading' }).dropped).toBe('no-turn') + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + expect(rig.assembler.apply({ type: 'activity', text: 'Reading' }).dropped).toBeUndefined() + }) + + it('journals provider traffic no event covers as the shared fallback row', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'provider.frame', + frameKind: 'session/mystery_update', + payload: { message: 'Something new happened' } + }) + const frame = (await rig.rows()).find((row) => row.body.kind === 'status') + expect(frame?.body).toMatchObject({ kind: 'status' }) + expect(frame?.turnScope).toEqual({ kind: 'turn', turnItemId: providerTurnItemId('turn-1') }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts new file mode 100644 index 000000000000..0b1d20746c58 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -0,0 +1,201 @@ +// A real assembled lane for tests: grammar events → assembler → deferred sink queue → on-disk +// journal. Assertions read the journal back, so they check what a client sees. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalApprovalItem, + AgentJournalItemBody, + AgentJournalMessageItem, + AgentJournalRenderItem, + AgentJournalToolCallItem, + AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + createDeferredStructuredAgentSessionEventSink, + type StructuredAgentSessionEventSink +} from '../agent-session-wire/structured-agent-session-event-sink' +import { testEventSinkLogging } from '../agent-session-wire/structured-agent-session-logger-test-support' +import { + createProviderTimelineAssembler, + type ProviderTimelineAssembler, + type ProviderTimelineAssemblerDeps +} from './provider-timeline-assembler' +import { + createLegacyProviderTimelineIdentityScheme, + type ProviderTimelineItemFamily +} from './provider-timeline-identity' +import { providerTimelineSink, type ProviderTimelineSink } from './provider-timeline-plan' + +export const SESSION = 'session-timeline' +export const AGENT = 'grok' +export const GENERATION = 'gen-1' +export const NAMESPACE = 'provider-session-1' + +const scheme = createLegacyProviderTimelineIdentityScheme({ agent: AGENT, sessionId: SESSION }) + +/** The journal key the assembler gives a provider-keyed item. */ +export function providerItemId( + family: ProviderTimelineItemFamily, + key: string, + options: { namespace?: string; incarnation?: number } = {} +): string { + return agentJournalItemKey( + scheme.item({ + namespace: options.namespace ?? NAMESPACE, + family, + key: { source: 'provider', value: key }, + thread: null, + turn: null, + itemClass: 'message', + messageOrdinal: null, + incarnation: options.incarnation ?? 1 + }) + ) +} + +/** The journal key of a provider-keyed turn's row. */ +export function providerTurnItemId(turnKey: string, namespace = NAMESPACE): string { + return agentJournalItemKey( + scheme.turn({ namespace, key: { source: 'provider', value: turnKey } }) + ) +} + +/** The turn id a provider-keyed turn's row carries. */ +export function providerTurnId(turnKey: string, namespace = NAMESPACE): string { + return scheme.turnId({ namespace, key: { source: 'provider', value: turnKey } }) +} + +export function runningTool(name: string): AgentJournalToolCallItem { + return { kind: 'tool-call', name, input: { name }, state: 'running' } +} + +export function assistantText(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +export const pendingApproval: AgentJournalApprovalItem = { + kind: 'approval', + title: 'Run?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } +} + +export function messageText(body: AgentJournalItemBody | undefined): string | undefined { + return body?.kind === 'message' && body.blocks[0]?.type === 'text' + ? body.blocks[0].text + : undefined +} + +const journals = createTrackedJournalOpener() +const cleanups: (() => Promise)[] = [] + +/** Call from `afterEach`. */ +export async function closeProviderTimelineRigs(): Promise { + for (const cleanup of cleanups.splice(0)) { + await cleanup() + } + await journals.closeAll() +} + +export type ProviderTimelineRig = { + journal: AgentSessionJournal + assembler: ProviderTimelineAssembler + sink: ProviderTimelineSink + /** The same journal's event sink, for a lane that writes it directly. */ + eventSink: StructuredAgentSessionEventSink + /** A fresh assembler on the same journal, as a restarted host builds. */ + restart(overrides?: Partial): ProviderTimelineAssembler + rows(): Promise + row(itemId: string): Promise + /** The turn row of provider turn `turnKey`, or the row whose turn id is `turnKey`. */ + turn(turnKey: string, namespace?: string): Promise + turns(): Promise +} + +/** The rig's sink with its transitions refused while `refusing()` holds. */ +export function refusingSink( + sink: ProviderTimelineSink, + refusing: () => boolean, + reason: 'backpressure' | 'failed' = 'backpressure' +): ProviderTimelineSink { + return { + ...sink, + tryAppendTransition: (transition) => + refusing() ? { accepted: false, reason } : sink.tryAppendTransition(transition) + } +} + +export async function openProviderTimelineRig( + overrides: Partial = {} +): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-provider-timeline-')) + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: AGENT, + providerHandle: { kind: 'opaque', agent: AGENT, value: 'provider-session-1' } + }, + stateDirectory: root, + now: () => 1_000 + }) + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) + deferred.bind({ journal, fence: 1, publish: () => {} }) + cleanups.push(async () => { + deferred.close() + await rm(root, { recursive: true, force: true }) + }) + const sink = providerTimelineSink(deferred.sink) + if (!sink) { + throw new Error('the deferred sink offers transitions') + } + const build = (more: Partial = {}) => + createProviderTimelineAssembler({ + sink, + sessionId: SESSION, + agent: AGENT, + generation: GENERATION, + namespace: NAMESPACE, + // Every delta writes at once unless a test drives the window itself. + schedule: (run) => { + run() + return () => {} + }, + ...overrides, + ...more + }) + const rows = async () => { + await deferred.drained() + return journal.snapshot().items + } + const turns = async () => + (await rows()).flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn ? [turn] : [] + }) + return { + journal, + assembler: build(), + sink, + eventSink: deferred.sink, + restart: build, + rows, + row: async (itemId) => (await rows()).find((item) => item.itemId === itemId), + turns, + turn: async (turnKey, namespace) => { + const all = await turns() + return ( + all.find((turn) => turn.turnId === providerTurnId(turnKey, namespace)) ?? + all.find((turn) => turn.turnId === turnKey) + ) + } + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts new file mode 100644 index 000000000000..69fa01b3f9d1 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + providerTurnId, + providerTurnItemId +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +describe('provider timeline turns', () => { + it('opens a running turn and settles it with the provider verdict and duration', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'send-1', requestedAt: 900 }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + expect(rig.assembler.openTurnId).toBe(providerTurnId('turn-1')) + expect(await rig.turn('turn-1')).toEqual({ + turnId: providerTurnId('turn-1'), + state: 'running', + userItemId: agentJournalSubmissionKey('send-1'), + startedAt: 1_000, + requestedAt: 900 + }) + const runningRow = await rig.row(providerTurnItemId('turn-1')) + // The running row is stamped at the turn start, not at append time. + expect(runningRow?.observedAt).toBe(1_000) + + rig.assembler.apply({ + type: 'turn.end', + at: 3_000, + state: 'completed', + outcome: 'success', + durationMs: 1_800 + }) + expect(rig.assembler.openTurnId).toBeNull() + const row = await rig.row(providerTurnItemId('turn-1')) + expect(row?.body).toEqual({ + kind: 'turn', + turnId: providerTurnId('turn-1'), + state: 'completed', + outcome: 'success', + userItemId: agentJournalSubmissionKey('send-1'), + startedAt: 1_000, + requestedAt: 900, + completedAt: 3_000, + durationMs: 1_800 + }) + // Turn rows belong to no turn, and are revised rather than replaced. + expect(row?.turnScope).toEqual({ kind: 'thread' }) + expect(row?.revision).toBeGreaterThan(runningRow?.revision ?? Infinity) + }) + + it('records a cancelled turn as interrupted with the cancellation verdict', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'turn.end', + at: 2_000, + state: 'interrupted', + outcome: 'cancellation' + }) + expect(await rig.turn('turn-1')).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation', + completedAt: 2_000 + }) + }) + + it('keeps a provider-reported failure a completed turn whose verdict says it failed', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'failure' }) + expect(await rig.turn('turn-1')).toMatchObject({ state: 'completed', outcome: 'failure' }) + }) + + it('leaves the verdict absent when the provider gave none', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + const turn = await rig.turn('turn-1') + expect(turn?.state).toBe('completed') + expect(turn).not.toHaveProperty('outcome') + }) + + it('mints a turn id when the provider names none, and keys a turn no send opened by its own row', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', at: 1_000 }) + const turnId = rig.assembler.openTurnId + expect(turnId).toMatch(/^m:gen-1/) + const rows = await rig.rows() + const [row] = rows + expect(rows).toHaveLength(1) + expect(row?.body).toMatchObject({ kind: 'turn', turnId, userItemId: row?.itemId }) + }) + + it('names the open turn with a send that arrives after a provider-opened turn began', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'send-1', requestedAt: 1_100 }) + // A second send folds into the turn; it does not replace the opener. + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'send-2', requestedAt: 1_200 }) + expect(await rig.turn('turn-1')).toMatchObject({ + userItemId: agentJournalSubmissionKey('send-1'), + requestedAt: 1_100 + }) + }) + + it('gives each queued send to the next turn that opens, in order', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'send-1', requestedAt: 900 }) + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'send-2', requestedAt: 950 }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 1_500, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-2', at: 2_000 }) + expect((await rig.turn('turn-1'))?.userItemId).toBe(agentJournalSubmissionKey('send-1')) + expect((await rig.turn('turn-2'))?.userItemId).toBe(agentJournalSubmissionKey('send-2')) + }) + + it('ends a still-open turn as superseded when the provider opens a different one', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-2', at: 2_000 }) + expect(await rig.turn('turn-1')).toMatchObject({ + state: 'interrupted', + outcome: 'superseded', + completedAt: 2_000 + }) + expect(await rig.turn('turn-2')).toMatchObject({ state: 'running', startedAt: 2_000 }) + }) + + it('drops a repeated open, a repeated end, and an end for a turn it never opened', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + expect(rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_100 }).dropped).toBe( + 'turn-duplicate' + ) + // An open naming no turn while one is open is the same turn, not a new one. + expect(rig.assembler.apply({ type: 'turn.open', at: 1_200 }).dropped).toBe('turn-duplicate') + expect( + rig.assembler.apply({ type: 'turn.end', turn: 'turn-9', at: 1_500, state: 'completed' }) + .dropped + ).toBe('turn-unknown') + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + expect( + rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 3_000, state: 'interrupted' }) + .dropped + ).toBe('turn-unknown') + expect(rig.assembler.apply({ type: 'turn.end', at: 3_000, state: 'completed' }).dropped).toBe( + 'no-turn' + ) + // A replayed open of a settled turn neither reopens nor rewrites it. + expect(rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 4_000 }).dropped).toBe( + 'turn-replayed' + ) + expect(await rig.turn('turn-1')).toMatchObject({ + state: 'completed', + outcome: 'success', + startedAt: 1_000, + completedAt: 2_000 + }) + }) + + it('never rewrites a settled turn when a restarted host replays its history', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + await rig.rows() + + // A new host process has no memory of the turn; only the journal knows it ended. + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ type: 'turn.open', turn: 'turn-1', at: 5_000 }) + restarted.apply({ type: 'turn.end', at: 6_000, state: 'interrupted', outcome: 'cancellation' }) + expect(await rig.turn('turn-1')).toEqual({ + turnId: providerTurnId('turn-1'), + state: 'completed', + outcome: 'success', + userItemId: providerTurnItemId('turn-1'), + startedAt: 1_000, + completedAt: 2_000 + }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts new file mode 100644 index 000000000000..480e40c88bac --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -0,0 +1,250 @@ +// Turns a provider adapter's grammar events (`provider-timeline-event.ts`) into the journal rows +// every structured lane writes. +// +// Division of lifecycle work: +// - The adapter translates its dialect, decides when a turn opens, and re-applies an event the sink +// refused (the lane runner holds it and pauses reading under backpressure). +// - The assembler plans each event as ONE sink transition. Which row each write lands on, whether +// a replay writes at all, and every change to what the assembler knows (its ledger) are decided +// in the transition's resolvers, at the event's turn in the journal's write queue, against the +// journal as it stands then. So there is one clock: a refused event changed nothing, a write the +// journal rejects changes nothing, and a restart or an evicted cache finds the same rows again. +// - Planning reads a forecast — the ledger plus what admitted events still in the queue will +// change — only to answer `apply` at once: dropped or not, over budget or not, the open turn. +// - The journal keeps what it owns: a person's Stop, a dead generation after a restart, and the +// answer compare-and-set. + +import type { AgentType } from '../../../shared/agent-session-journal-types' +import type { AgentSessionDeltaCoalescerDeps } from '../agent-session-wire/agent-session-delta-coalescer' +import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { + ProviderTimelineApplyHost, + ProviderTimelineApplyResult, + ProviderTimelineInFlight +} from './provider-timeline-apply-host' +import { + PROVIDER_TIMELINE_OVER_BUDGET, + providerTimelineBudgetAdmits, + type ProviderTimelineHold +} from './provider-timeline-budget' +import { providerTimelineLedger, type ProviderTimelineContext } from './provider-timeline-context' +import { + decideProviderTimelineEvent, + type ProviderTimelineDecidedEvent, + type ProviderTimelineDecision +} from './provider-timeline-decisions' +import type { ProviderTimelineEvent } from './provider-timeline-event' +import { + createLegacyProviderTimelineIdentityScheme, + type ProviderTimelineIdentityScheme +} from './provider-timeline-identity' +import { ProviderTimelineJoins } from './provider-timeline-joins' +import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' +import { ProviderTimelineState } from './provider-timeline-state' +import { + applyProviderTimelineTextClose, + applyProviderTimelineTextDelta +} from './provider-timeline-text-events' +import { ProviderTimelineTextStreams } from './provider-timeline-text-streams' +import { + planProviderTimelineBarrier, + planProviderTimelineSlots +} from './provider-timeline-transition-layout' + +export type { ProviderTimelineApplyResult } from './provider-timeline-apply-host' +export type { ProviderTimelineDropRule } from './provider-timeline-decisions' + +export type ProviderTimelineAssembler = { + apply(event: ProviderTimelineEvent): ProviderTimelineApplyResult + /** The turn id of the open turn, as its row and a client's Stop name it. */ + readonly openTurnId: string | null + /** Writes the text the coalescing window holds. */ + flush(): void + dispose(): void +} + +export type ProviderTimelineAssemblerDeps = { + sink: ProviderTimelineSink + sessionId: string + agent: AgentType + /** The acquisition: minted keys are unique per generation. */ + generation: string + /** The provider session whose ids the adapter forwards; the same one again after a restart that + * re-attaches it, so its replays are recognised. */ + namespace: string + /** The session's own provider thread, for providers that run subagents on threads of their own. */ + ownThread?: () => string | null + /** Defaults to the shared `legacy` identity arm. */ + scheme?: ProviderTimelineIdentityScheme + coalesceMs?: number + schedule?: AgentSessionDeltaCoalescerDeps['schedule'] +} + +const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +export function createProviderTimelineAssembler( + deps: ProviderTimelineAssemblerDeps +): ProviderTimelineAssembler { + const scheme = + deps.scheme ?? + createLegacyProviderTimelineIdentityScheme({ agent: deps.agent, sessionId: deps.sessionId }) + let settlements = 0 + const context: ProviderTimelineContext = { + sessionId: deps.sessionId, + agent: deps.agent, + joins: new ProviderTimelineJoins({ + scheme, + generation: deps.generation, + namespace: deps.namespace + }), + ledger: new ProviderTimelineState(deps.namespace), + ...(deps.ownThread ? { ownThread: deps.ownThread } : {}), + settlementId: (what) => { + settlements += 1 + return `provider-timeline:${deps.sessionId}:${deps.generation}:${settlements}:${what}` + } + } + const streams = new ProviderTimelineTextStreams({ + sink: deps.sink, + context, + generation: deps.generation, + ...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }), + ...(deps.schedule ? { schedule: deps.schedule } : {}) + }) + let forecast = context.ledger.clone() + const inFlight: ProviderTimelineInFlight[] = [] + + /** The forecast again: the ledger, plus what admitted events not yet run will change. */ + const resync = () => { + forecast = context.ledger.clone() + for (const entry of inFlight) { + if (!entry.executed) { + entry.commit?.(forecast) + } + } + } + + const submit = ( + plan: ProviderTimelinePlan, + entry: InFlight + ): StructuredAgentSessionSinkAdmission => { + if (!plan.writes) { + return plan.submit(deps.sink) + } + inFlight.push(entry) + const settle = () => { + const at = inFlight.indexOf(entry) + if (at !== -1) { + inFlight.splice(at, 1) + resync() + } + } + const admission = plan.submit(deps.sink, settle) + if (!admission.accepted) { + inFlight.splice(inFlight.indexOf(entry), 1) + return admission + } + entry.commit?.(forecast) + return admission + } + + const admits = (hold: ProviderTimelineHold): boolean => + providerTimelineBudgetAdmits({ + hold, + forecast, + ledger: context.ledger, + streams: streams.open, + journal: deps.sink.journalItems() + }) + + const host: ProviderTimelineApplyHost = { + context, + streams, + forecast: () => forecast, + journal: () => deps.sink.journalItems(), + admits, + submit + } + + const applyDecided = (event: ProviderTimelineDecidedEvent): ProviderTimelineApplyResult => { + const journal = deps.sink.journalItems() + const decision = decideProviderTimelineEvent( + { context, state: forecast, journal, execute: false }, + event + ) + if (decision.dropped) { + return { admission: ADMITTED, dropped: decision.dropped } + } + if (decision.hold && !admits(decision.hold)) { + return { admission: PROVIDER_TIMELINE_OVER_BUDGET } + } + const plan = new ProviderTimelinePlan() + planProviderTimelineBarrier(host, plan, event, decision) + const entry: ProviderTimelineInFlight = { executed: false, commit: decision.commit } + let executed: ProviderTimelineDecision | null = null + // The event's decision on the ledger, taken once, by its first slot to run. + const decide = (at: StructuredAgentSessionTransitionJournal) => { + if (!executed) { + const ledger = providerTimelineLedger(context, at) + executed = decideProviderTimelineEvent( + { context, state: ledger, journal: at, execute: true }, + event + ) + entry.executed = true + executed.commit?.(ledger) + } + return executed.dropped ? null : executed + } + planProviderTimelineSlots(host, plan, event, decide) + if ( + event.type === 'turn.open' || + event.type === 'turn.end' || + event.type === 'session.ended' || + event.type === 'session.reset' + ) { + plan.onAdmitted(() => deps.sink.setActivity?.(null)) + } + return { admission: submit(plan, entry) } + } + + const apply = (event: ProviderTimelineEvent): ProviderTimelineApplyResult => { + const journal = deps.sink.journalItems() + if (journal && !forecast.hydrated) { + forecast.hydrate(journal) + } + switch (event.type) { + case 'text.delta': + return applyProviderTimelineTextDelta(host, event) + case 'text.close': + return applyProviderTimelineTextClose(host, event) + case 'activity': { + const open = forecast.open + if (forecast.ended || !open) { + return { admission: ADMITTED, dropped: forecast.ended ? 'session-ended' : 'no-turn' } + } + deps.sink.setActivity?.( + event.text === null ? null : { turnId: open.turnId, text: event.text } + ) + return { admission: ADMITTED } + } + case 'turn.open': + return applyDecided( + event.turn === undefined ? { ...event, minted: context.joins.mint('t') } : event + ) + case 'provider.frame': + return applyDecided({ ...event, minted: context.joins.mint('f') }) + default: + return applyDecided(event) + } + } + + return { + apply, + get openTurnId() { + return forecast.open?.turnId ?? null + }, + flush: () => streams.flush(), + dispose: () => streams.dispose() + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts new file mode 100644 index 000000000000..864a193818fa --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts @@ -0,0 +1,66 @@ +// One admission budget for everything the assembler holds open: running items, pending requests +// and open text streams. It is derived, not booked: the open set is the forecast's obligations +// plus the open streams, one entry per item (an item and its stream share its join reference) and +// one per request key whatever its incarnation. Before refusing, the set is re-derived from the +// journal, so an answer a client gave or a row another writer settled frees its room. Past the +// budget an event is refused as `failed`, which ends the session the way a failed journal write +// does, and the journal-derived dead-generation settlement closes whatever it left open. + +import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' +import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { ProviderTimelineState } from './provider-timeline-state' + +export const MAX_PROVIDER_TIMELINE_OPEN_ENTRIES = 128 +export const MAX_PROVIDER_TIMELINE_OPEN_BYTES = 1024 * 1024 + +export const PROVIDER_TIMELINE_OVER_BUDGET: StructuredAgentSessionSinkAdmission = { + accepted: false, + reason: 'failed' +} + +export type ProviderTimelineHold = { key: string; bytes: number } + +export function providerTimelineBudgetAdmits(input: { + hold: ProviderTimelineHold + forecast: ProviderTimelineState + ledger: ProviderTimelineState + streams: readonly ProviderTimelineHold[] + journal: StructuredAgentSessionTransitionJournal | null +}): boolean { + if (fits(input)) { + return true + } + // The fold is never behind the ledger, so what it shows settled is settled in both. + for (const state of [input.forecast, input.ledger]) { + for (const [key, obligation] of state.obligations) { + const body = obligation.itemId ? input.journal?.itemBody(obligation.itemId) : null + if (body && !requiresTerminalSettlement(body)) { + state.obligations.delete(key) + } + } + } + return fits(input) +} + +function fits(input: { + hold: ProviderTimelineHold + forecast: ProviderTimelineState + streams: readonly ProviderTimelineHold[] +}): boolean { + const open = new Map() + for (const [key, obligation] of input.forecast.obligations) { + open.set(key, obligation.bytes) + } + for (const stream of input.streams) { + open.set(stream.key, Math.max(open.get(stream.key) ?? 0, stream.bytes)) + } + open.set(input.hold.key, Math.max(open.get(input.hold.key) ?? 0, input.hold.bytes)) + let bytes = 0 + for (const each of open.values()) { + bytes += each + } + return ( + open.size <= MAX_PROVIDER_TIMELINE_OPEN_ENTRIES && bytes <= MAX_PROVIDER_TIMELINE_OPEN_BYTES + ) +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts new file mode 100644 index 000000000000..b451973b6710 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts @@ -0,0 +1,67 @@ +// What every planner and resolver of one assembler shares, and where a joined row goes. + +import type { + AgentJournalProducerLinkage, + AgentType +} from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { ProviderTimelineJoin } from './provider-timeline-event' +import type { ProviderTimelineJoins, ProviderTimelinePlacement } from './provider-timeline-joins' +import type { ProviderTimelineState } from './provider-timeline-state' + +export type ProviderTimelineContext = { + sessionId: string + agent: AgentType + joins: ProviderTimelineJoins + /** The truth decisions run on; resolvers only. */ + ledger: ProviderTimelineState + /** The session's own provider thread; a join naming another thread is subagent work. */ + ownThread?: () => string | null + /** A settlement id no other settlement of this journal shares. */ + settlementId(what: string): string +} + +/** The ledger, with its open turn taken from the journal before its first decision runs. */ +export function providerTimelineLedger( + context: ProviderTimelineContext, + journal: StructuredAgentSessionTransitionJournal +): ProviderTimelineState { + if (!context.ledger.hydrated) { + context.ledger.hydrate(journal) + } + return context.ledger +} + +/** The turn a new row joins: the one the provider names (subagent work joins the open turn while + * keeping its own turn in its identity), else the open one, else none. */ +export function providerTimelinePlacement( + context: ProviderTimelineContext, + state: ProviderTimelineState, + join: ProviderTimelineJoin | undefined +): ProviderTimelinePlacement { + const thread = join?.thread ?? null + if (join?.turn === undefined) { + return { thread, turn: state.open?.address.key ?? null, scope: state.scope } + } + const key = { source: 'provider', value: join.turn } as const + const own = context.ownThread?.() ?? null + const subagent = thread !== null && own !== null && thread !== own + return { + thread, + turn: key, + scope: subagent + ? state.scope + : { kind: 'turn', turnItemId: context.joins.turn(key, state.namespace).itemId } + } +} + +/** What an entry the assembler holds open costs: every provider string it keeps, and its body. */ +export function providerTimelineEntryBytes( + key: string, + body?: unknown, + producer?: AgentJournalProducerLinkage +): number { + const measure = (value: unknown) => + value === undefined ? 0 : Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8') + return Buffer.byteLength(key, 'utf8') + measure(body) + measure(producer) + 64 +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts new file mode 100644 index 000000000000..ae75f819f524 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -0,0 +1,102 @@ +// The shape of a decision on one non-text event, and the reads every rule shares. + +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { StructuredAgentSessionItemAppendOptions } from '../agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { ProviderTimelineHold } from './provider-timeline-budget' +import type { ProviderTimelineContext } from './provider-timeline-context' +import type { ProviderTimelineEvent } from './provider-timeline-event' +import type { ProviderTimelineKey } from './provider-timeline-identity' +import type { ProviderTimelineTurnRef } from './provider-timeline-joins' +import { + providerTimelineSettlement, + type ProviderTimelineTurnEnd +} from './provider-timeline-settlement' +import type { ProviderTimelineState } from './provider-timeline-state' + +/** Why an event wrote nothing. Each is a grammar rule the adapter broke or a replay it repeated. */ +export type ProviderTimelineDropRule = + | 'session-ended' + | 'turn-duplicate' + | 'turn-replayed' + | 'turn-unknown' + | 'no-turn' + | 'item-settled' + | 'item-replayed' + | 'request-duplicate' + | 'request-replayed' + | 'request-unknown' + | 'stream-unknown' + | 'stream-mismatch' + +/** A non-text event with the keys planning minted for it, so execution names the same ones. */ + +/** A non-text event with the keys planning minted for it, so execution names the same ones. */ +export type ProviderTimelineDecidedEvent = Exclude< + ProviderTimelineEvent, + { type: 'text.delta' | 'text.close' | 'activity' } +> & { minted?: ProviderTimelineKey } + +export type ProviderTimelineResolvedWrite = { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + options?: StructuredAgentSessionItemAppendOptions +} + +export type ProviderTimelineDecision = { + dropped?: ProviderTimelineDropRule + /** What the event would hold open, for the budget. */ + hold?: ProviderTimelineHold + /** The event's change to what is known: the forecast's at admission, the ledger's at execution. */ + commit?: (state: ProviderTimelineState) => void + /** Execution only: the settlement step's mutations and the item step's write. */ + settle?: readonly JournalLifecycleMutationInput[] + write?: ProviderTimelineResolvedWrite | null + /** The item the event closes, whose stream it releases. */ + closes?: string +} + +export type ProviderTimelineDecisionInput = { + context: ProviderTimelineContext + state: ProviderTimelineState + journal: StructuredAgentSessionTransitionJournal | null + /** Execution: place rows and compute writes. Planning never does. */ + execute: boolean +} + +export const providerKey = (value: string): ProviderTimelineKey => ({ source: 'provider', value }) + +export function settledTool(body: AgentJournalItemBody | null): boolean { + return body?.kind === 'tool-call' && body.state !== 'running' +} + +export function runningTool(body: AgentJournalItemBody): boolean { + return body.kind === 'tool-call' && body.state === 'running' +} + +export function pendingPrompt(body: AgentJournalItemBody | null): boolean { + return ( + (body?.kind === 'approval' || body?.kind === 'question') && body.resolution.state === 'pending' + ) +} + +export function turnOf(scope: { kind: string; turnItemId?: string }): string | null { + return scope.kind === 'turn' ? (scope.turnItemId ?? null) : null +} + +/** A turn's journal-derived settlement, at execution. */ +export function settlementOf( + input: ProviderTimelineDecisionInput, + turn: ProviderTimelineTurnRef, + end: ProviderTimelineTurnEnd +): readonly JournalLifecycleMutationInput[] | undefined { + if (!input.execute || !input.journal) { + return undefined + } + const scope = { turnItemId: turn.itemId, outlivesTurn: input.state.outliving(turn.itemId) } + return providerTimelineSettlement(input.journal, scope, [turn], end) +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts new file mode 100644 index 000000000000..7f03e7267c00 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts @@ -0,0 +1,76 @@ +// What each non-text event does: one rule set, run twice. +// +// Planning runs it on the forecast and the journal as it stands, to answer `apply` (dropped or +// not, and what it would hold open) without writing or allocating anything. Execution runs it +// again in the event's first resolver, on the ledger and the fold at that point in the journal's +// write queue: only then are rows placed, ordinals and incarnations taken, and the ledger changed. +// So when the two disagree — before bind, after a restart, behind a replay the journal rejects — +// the journal's answer is the one that lands, and the ledger follows it. + +import { + decideFrame, + decideItem, + decideRequest, + decideWithdrawal +} from './provider-timeline-item-decisions' +import type { + ProviderTimelineDecidedEvent, + ProviderTimelineDecision, + ProviderTimelineDecisionInput +} from './provider-timeline-decision' +import { + decideContextUsage, + decideInput, + decideSessionEnd, + decideTurnEnd, + decideTurnOpen +} from './provider-timeline-turn-decisions' + +export type { + ProviderTimelineDecidedEvent, + ProviderTimelineDecision, + ProviderTimelineDropRule +} from './provider-timeline-decision' + +export function decideProviderTimelineEvent( + input: ProviderTimelineDecisionInput, + event: ProviderTimelineDecidedEvent +): ProviderTimelineDecision { + if (input.state.ended && event.type !== 'session.reset') { + return { dropped: 'session-ended' } + } + switch (event.type) { + case 'input.accepted': + return decideInput(input, event) + case 'turn.open': + return decideTurnOpen(input, event) + case 'turn.end': + return decideTurnEnd(input, event) + case 'item.open': + case 'item.update': + case 'item.close': + return decideItem(input, event) + case 'request.open': + return decideRequest(input, event) + case 'request.withdrawn': + return decideWithdrawal(input, event) + case 'context.usage': + return decideContextUsage(input, event) + case 'provider.frame': + return decideFrame(input, event) + case 'session.ended': + return decideSessionEnd(input, event.verdict, (state) => { + state.ended = true + state.endSession() + }) + case 'session.reset': + // Nothing ended the old session's open work, so it is lost, not interrupted; the resolver + // finds nothing when an end already settled it. + return decideSessionEnd(input, { state: 'unverifiable' }, (state) => { + state.reset(event.namespace) + if (input.execute) { + input.context.joins.reset(event.namespace) + } + }) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts new file mode 100644 index 000000000000..1a1ca6ad1746 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -0,0 +1,128 @@ +// The narrow grammar a provider adapter speaks to the timeline assembler. +// +// The adapter knows the provider's dialect; the assembler knows the timeline. An adapter parses +// provider traffic into these semantic events and never writes a journal row or mints a journal +// identity: every key here is the provider's own id (a turn, a tool call, a message, a thread), +// or a name the adapter keeps stable for something the provider does not name. The assembler +// resolves those joins to rows, scopes rows to turns, and owns the turn and item rules in +// `agent-session-journal-types.ts`; the journal decides, at each write, what a replay changes. +// +// Rules an adapter can rely on: +// - Only `turn.open` opens a turn. Items, text and requests never do: one that arrives with no +// turn open and names none is written as a thread row. An adapter whose provider starts work on +// its own (a background wake, auto-compaction) decides that a turn began and says `turn.open` +// before that work's items. +// - `join.turn` attaches a row to the turn the provider says it belongs to, open or not. +// - A turn settles once, with only the provider's verdict; the journal refuses a second settlement +// and a replayed open, even after a restart. +// - An event the sink refused changed nothing. Re-apply the same event to retry it (after +// `backpressure`); `failed` and `closed` are final. +// - A provider item is (thread, id): the same id on another thread is another item. Text and +// full snapshots of one id are one row, and its close settles it for both. +// - `dropped` is the verdict when the event was applied. The journal has the last word when the +// event's writes run: a replay the journal already holds writes nothing even if it was taken. +// - After `session.ended` every event is dropped until `session.reset`. Reset whenever the +// provider's ids may repeat (a new process): its ids then name new rows. + +import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' +import type { + AgentJournalApprovalItem, + AgentJournalItemBody, + AgentJournalProducerLinkage, + AgentJournalQuestionItem, + AgentJournalTurnOutcome +} from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionTurnVerdict } from '../agent-session-wire/structured-agent-session-stale-turn-verdict' + +/** Bodies an item event may carry. Turn rows are the assembler's; prompts travel as requests. */ +export type ProviderTimelineItemBody = Exclude< + AgentJournalItemBody, + { kind: 'turn' | 'approval' | 'question' } +> + +export type ProviderTimelineRequestBody = AgentJournalApprovalItem | AgentJournalQuestionItem + +/** `assistant` is reply text; `reasoning` is the model's visible thinking. */ +export type ProviderTimelineTextChannel = 'assistant' | 'reasoning' + +/** Where the provider says something belongs. */ +export type ProviderTimelineJoin = { + /** The provider thread it came from, when the provider runs several (a subagent's own thread). */ + thread?: string + /** The provider turn it belongs to. Absent: the turn open now. */ + turn?: string +} + +/** A streamed text item: one the provider names (the same item as item events with that id), or + * an anonymous stream that becomes a new message each time it starts. */ +export type ProviderTimelineTextItem = { id: string } | { stream: string } + +type Produced = { + /** The subagent that produced this, when not the session's own agent. Stamped on the row as is. */ + producer?: AgentJournalProducerLinkage +} + +type Joined = { join?: ProviderTimelineJoin } + +export type ProviderTimelineEvent = + /** Orca's send reached the provider. It names the open turn when nothing opened that one, else + * the next. `join.item`: the provider's id for its echo of the send, which takes that message's + * place among its turn's messages and writes no row (the send's own row is the bubble). */ + | { + type: 'input.accepted' + clientMessageId: string + requestedAt: number + join?: ProviderTimelineJoin & { item?: string } + } + /** A turn began. `turn` is the provider's turn id when it has one; the assembler mints one otherwise. */ + | { type: 'turn.open'; turn?: string; at: number } + /** The provider ended a turn. Absent `turn` means the open turn. */ + | { + type: 'turn.end' + turn?: string + at: number + state: 'completed' | 'interrupted' + /** The provider's own verdict. Absent means it gave none, which reads as unknown. */ + outcome?: AgentJournalTurnOutcome + /** The provider's own measured duration. */ + durationMs?: number + } + /** Work began. `outlivesTurn` keeps it open past its turn's end (a backgrounded task). */ + | ({ + type: 'item.open' + item: string + body: ProviderTimelineItemBody + outlivesTurn?: true + } & Produced & + Joined) + /** The item's whole current body. Content may be replaced; a settled tool never runs again. */ + | ({ type: 'item.update'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) + /** The item's whole terminal body; it replaces any text streamed into the same item. */ + | ({ type: 'item.close'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) + /** Streamed text. */ + | ({ + type: 'text.delta' + item: ProviderTimelineTextItem + channel: ProviderTimelineTextChannel + text: string + } & Produced & + Joined) + /** The stream ended; `text` is the provider's final text, else what streamed is kept. `join` + * names the same item its deltas named. */ + | ({ type: 'text.close'; item: ProviderTimelineTextItem; text?: string } & Joined) + /** The provider asked the user something and waits on the answer. */ + | ({ type: 'request.open'; request: string; body: ProviderTimelineRequestBody } & Produced & + Joined) + /** The provider stopped waiting for an answer it never got. */ + | { type: 'request.withdrawn'; request: string } + /** What the provider said about its context window: for `join.turn`, else the open turn, else the last. */ + | ({ type: 'context.usage'; usage: AgentSessionContextUsage } & Joined) + /** The live activity line for the open turn; null clears it. */ + | { type: 'activity'; text: string | null } + /** Provider traffic no typed event covers; it becomes the shared bounded fallback row. */ + | ({ type: 'provider.frame'; frameKind: string; payload: unknown } & Joined) + /** The provider child is gone. The verdict is what the host can prove about its end. */ + | { type: 'session.ended'; verdict: StructuredAgentSessionTurnVerdict } + /** A new provider session began; its ids live in `namespace`. Work still open is settled first, + * as lost (`unverifiable`), since nothing ended it. */ + | { type: 'session.reset'; namespace: string } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts new file mode 100644 index 000000000000..90200b66bae7 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts @@ -0,0 +1,196 @@ +// What item, request and frame events do, decided on the forecast and again on the ledger. + +import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' +import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' +import { unhandledProviderFrameJournalItem } from '../agent-session-wire/unhandled-provider-frame' +import { providerTimelineEntryBytes, providerTimelinePlacement } from './provider-timeline-context' +import { + pendingPrompt, + providerKey, + runningTool, + settledTool, + turnOf, + type ProviderTimelineDecidedEvent, + type ProviderTimelineDecision, + type ProviderTimelineDecisionInput +} from './provider-timeline-decision' +import { providerTimelineItemClass, type ProviderTimelineKey } from './provider-timeline-identity' +import type { ProviderTimelineItemJoin } from './provider-timeline-joins' + +export function decideItem( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal, context } = input + const change = + event.type === 'item.open' ? 'open' : event.type === 'item.update' ? 'update' : 'close' + const join: ProviderTimelineItemJoin = { + family: 'item', + key: providerKey(event.item), + thread: event.join?.thread ?? null + } + const ref = context.joins.reference(join, state.namespace) + const found = context.joins.find(join, journal, state.namespace) + const held = found ? (journal?.itemBody(found.itemId) ?? null) : null + // This run closed it: an open reopens the same row, a second close is a repeat. + const closed = state.closed.has(ref) + if (change === 'open' && !closed && held) { + return { dropped: 'item-replayed' } + } + if (change === 'update' && runningTool(event.body) && (closed || settledTool(held))) { + return { dropped: 'item-settled' } + } + if (change === 'close' && (closed || settledTool(held))) { + return { dropped: 'item-settled' } + } + const known = state.obligations.get(ref) + const outlivesTurn = + (event.type === 'item.open' && event.outlivesTurn === true) || known?.outlivesTurn === true + const obligation = change !== 'close' && (requiresTerminalSettlement(event.body) || outlivesTurn) + const row = + found ?? + (input.execute && journal + ? context.joins.place( + join, + providerTimelineItemClass(event.body), + providerTimelinePlacement(context, state, event.join), + journal + ) + : null) + const scope = row?.scope ?? providerTimelinePlacement(context, state, event.join).scope + const turnItemId = turnOf(scope) + const bytes = providerTimelineEntryBytes(event.item, event.body, event.producer) + return { + ...(obligation ? { hold: { key: ref, bytes } } : {}), + write: row && { + identity: row.identity, + body: event.body, + options: { + ...event.producer, + turnScope: row.scope, + ...(row.ref === undefined ? {} : { providerItemRef: row.ref }) + } + }, + ...(change === 'close' ? { closes: ref } : {}), + commit: (next) => { + if (obligation) { + next.obligations.set(ref, { itemId: row?.itemId ?? null, turnItemId, outlivesTurn, bytes }) + } else { + next.obligations.delete(ref) + } + if (change === 'close') { + next.closed.set(ref, turnItemId) + } else if (change === 'open') { + next.closed.delete(ref) + } + } + } +} + +export function requestRef(input: ProviderTimelineDecisionInput, key: ProviderTimelineKey): string { + return input.context.joins.reference( + { family: 'request', key, thread: null }, + input.state.namespace + ) +} + +export function decideRequest( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal, context } = input + const key = providerKey(event.request) + const ref = requestRef(input, key) + const current = context.joins.request(key, journal, state.namespace) + const held = current ? (journal?.itemBody(current.itemId) ?? null) : null + // Pending in the journal, or opened here and not landed yet: the same request again. + if (pendingPrompt(held) || (state.obligations.has(ref) && held === null)) { + return { dropped: 'request-duplicate' } + } + const settledTurn = current ? turnOf(current.scope) : null + if ( + held && + settledTurn !== null && + state.status({ itemId: settledTurn }, journal) === 'settled' + ) { + return { dropped: 'request-replayed' } + } + const placement = providerTimelinePlacement(context, state, event.join) + const row = + input.execute && journal + ? context.joins.nextRequest(key, event.body.kind, placement, journal) + : null + const bytes = providerTimelineEntryBytes(event.request, event.body, event.producer) + return { + hold: { key: ref, bytes }, + // A row already there is a client's answer or a replay; neither is overwritten. + write: + row && journal?.itemBody(row.itemId) === null + ? { + identity: row.identity, + body: event.body, + options: { ...event.producer, turnScope: row.scope, lifecycle: true } + } + : null, + commit: (next) => + next.obligations.set(ref, { + itemId: row?.itemId ?? null, + turnItemId: turnOf(row?.scope ?? placement.scope), + outlivesTurn: false, + bytes + }) + } +} + +export function decideWithdrawal( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal } = input + const key = providerKey(event.request) + const ref = requestRef(input, key) + // The latest incarnation the journal holds, however long ago this run (or another) opened it. + const current = input.context.joins.request(key, journal, state.namespace) + const held = current ? (journal?.itemBody(current.itemId) ?? null) : null + if (!pendingPrompt(held) && !(state.obligations.has(ref) && held === null)) { + return { dropped: 'request-unknown' } + } + // Only while the journal holds it pending: a client's answer that landed first stands. + const cancelled = current && held && pendingPrompt(held) ? cancelledJournalPromptBody(held) : null + return { + ...(input.execute + ? { + settle: + current && cancelled + ? [ + { + kind: 'item' as const, + identity: current.identity, + body: cancelled, + turnScope: current.scope + } + ] + : [] + } + : {}), + commit: (next) => next.obligations.delete(ref) + } +} + +export function decideFrame( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal, context } = input + const frame = unhandledProviderFrameJournalItem(context.agent, event.frameKind, event.payload) + if (!frame || !event.minted || !input.execute || !journal) { + return {} + } + const row = context.joins.place( + { family: 'frame', key: event.minted, thread: event.join?.thread ?? null }, + 'frame', + providerTimelinePlacement(context, state, event.join), + journal + ) + return { write: { identity: row.identity, body: frame.body, options: { turnScope: row.scope } } } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts new file mode 100644 index 000000000000..44dec71dc576 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts @@ -0,0 +1,144 @@ +// One grammar event's journal writes, admitted as one sink transition. +// +// Planning lays out the writes the event may need and changes nothing. What each write says, and +// which row it lands on, is decided by its resolver when the transition reaches its turn in the +// journal's write queue; those resolvers are the only code that changes the assembler's ledger. +// `onAdmitted` work (the forecast, text marked written) runs only when the sink takes the +// transition, so a refused event leaves the assembler exactly as it was and re-applying it is the +// retry. + +import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionSinkAdmission +} from '../agent-session-wire/structured-agent-session-event-sink' +import type { + StructuredAgentSessionTransitionJournal, + StructuredAgentSessionTransitionStep +} from '../agent-session-wire/structured-agent-session-transition' + +/** The sink calls the assembler needs: one-operation transitions, and the journal as it stands. */ +export type ProviderTimelineSink = Required< + Pick +> & + Pick + +type ItemStep = Extract +type SettlementStep = Extract + +const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +export class ProviderTimelinePlan { + private readonly steps: StructuredAgentSessionTransitionStep[] = [] + private readonly admitted: (() => void)[] = [] + private readonly executions: ((journal: StructuredAgentSessionTransitionJournal) => void)[] = [] + private lifecycle = false + private refusal: StructuredAgentSessionSinkAdmission | null = null + + item(step: Omit, lifecycle = false): void { + this.steps.push({ kind: 'item', ...step }) + this.lifecycle ||= lifecycle + } + + settlement(step: Omit): void { + this.steps.push({ kind: 'settlement', ...step }) + this.lifecycle = true + } + + /** Runs once the sink takes the transition. */ + onAdmitted(change: () => void): void { + this.admitted.push(change) + } + + /** Ledger work that rides the transition's place in the queue, after its last step resolved. */ + atExecution(change: (journal: StructuredAgentSessionTransitionJournal) => void): void { + this.executions.push(change) + } + + /** The event cannot be taken at all (over budget); nothing is submitted. */ + refuse(admission: StructuredAgentSessionSinkAdmission): void { + this.refusal ??= admission + } + + /** Whether submitting sends a transition (and so `landed` will be heard). */ + get writes(): boolean { + return this.steps.length > 0 || this.executions.length > 0 + } + + submit(sink: ProviderTimelineSink, landed?: () => void): StructuredAgentSessionSinkAdmission { + if (this.refusal) { + return this.refusal + } + if (this.executions.length > 0 && this.steps.length === 0) { + this.item({ + reservedBytes: 0, + resolve: () => null, + options: { turnScope: { kind: 'thread' } } + }) + } + if (this.steps.length > 0) { + const admission = sink.tryAppendTransition({ + steps: this.withExecutions(), + lifecycle: this.lifecycle, + publish: true, + ...(landed ? { landed: () => landed() } : {}) + }) + if (!admission.accepted) { + return admission + } + } + for (const change of this.admitted) { + change() + } + return ADMITTED + } + + private withExecutions(): StructuredAgentSessionTransitionStep[] { + const last = this.steps.at(-1) + if (!last || this.executions.length === 0) { + return this.steps + } + const executions = this.executions + const finish = (journal: StructuredAgentSessionTransitionJournal) => + executions.forEach((change) => change(journal)) + const wrapped: StructuredAgentSessionTransitionStep = + last.kind === 'item' + ? { + ...last, + resolve: (journal) => { + const resolved = last.resolve(journal) + finish(journal) + return resolved + } + } + : { + ...last, + resolve: (journal) => { + const resolved = last.resolve(journal) + finish(journal) + return resolved + } + } + return [...this.steps.slice(0, -1), wrapped] + } +} + +/** The sink as the assembler needs it; null for a sink without transitions or a journal view. */ +export function providerTimelineSink( + sink: StructuredAgentSessionEventSink +): ProviderTimelineSink | null { + const { tryAppendTransition, journalItems, setActivity } = sink + if (!tryAppendTransition || !journalItems) { + return null + } + return { + tryAppendTransition: (transition) => tryAppendTransition.call(sink, transition), + journalItems: () => journalItems.call(sink), + ...(setActivity + ? { + setActivity: (activity: AgentSessionTurnActivity | null) => + setActivity.call(sink, activity) + } + : {}) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts new file mode 100644 index 000000000000..390160e99e1a --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -0,0 +1,147 @@ +// What a turn's end or the session's end settles, read from the journal when the settlement runs. +// +// The same mechanism the dead-generation settlement uses after a restart: every row still waiting +// on the row that settles it — a running tool call, a pending prompt — is settled from its body as +// the journal holds it then. Nothing about open work is trusted from memory, so a prompt a client +// answered a moment earlier stays answered, a row a previous assembler opened is settled too, and +// a second settlement finds nothing left to do. + +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalItemIdentity, + type AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import { + agentJournalTurnBody, + readAgentJournalTurn +} from '../../../shared/agent-session-turn-record' +import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { + agentJournalTurnRowReservedBytes, + resolveAgentJournalTurnRowWrite +} from './agent-journal-turn-row-revision' + +/** How a turn ended: the provider's report, or what the host could prove when the child went. */ +export type ProviderTimelineTurnEnd = + | { + state: 'completed' | 'interrupted' + completedAt: number + outcome?: AgentJournalTurnLifecycle['outcome'] + durationMs?: number + } + | { state: 'unverifiable' } + +/** What a row still open when its turn or the session ends becomes; null when it already stands. */ +export function settledProviderTimelineBody( + body: AgentJournalItemBody +): AgentJournalItemBody | null { + if (body.kind === 'tool-call') { + return body.state === 'running' ? { ...body, state: 'failed' } : null + } + if (body.kind === 'approval' || body.kind === 'question') { + return body.resolution.state === 'pending' ? cancelledJournalPromptBody(body) : null + } + return null +} + +/** The end owns the turn's terminal fields; `unverifiable` carries no end and no verdict. Fields + * keep the order every lane writes them in, so the row is the same bytes whoever ends it. */ +export function endedProviderTimelineTurn( + running: AgentJournalTurnLifecycle, + end: ProviderTimelineTurnEnd +): AgentJournalTurnLifecycle { + const { turnId, userItemId, startedAt, requestedAt } = running + const opened = { + ...(userItemId !== undefined ? { userItemId } : {}), + ...(startedAt !== undefined ? { startedAt } : {}), + ...(requestedAt !== undefined ? { requestedAt } : {}) + } + if (end.state === 'unverifiable') { + return { turnId, state: 'unverifiable', ...opened } + } + return { + turnId, + state: end.state, + ...(end.outcome !== undefined ? { outcome: end.outcome } : {}), + ...opened, + completedAt: end.completedAt, + ...(end.durationMs !== undefined ? { durationMs: end.durationMs } : {}) + } +} + +/** Which open rows a settlement covers: one turn's, or every row when the session ends. */ +export type ProviderTimelineSettlementScope = + | { turnItemId: string; outlivesTurn: ReadonlySet } + | 'session' + +/** The settled rows of `scope`, then each listed turn's end while the journal holds it running. */ +export function providerTimelineSettlement( + journal: StructuredAgentSessionTransitionJournal, + scope: ProviderTimelineSettlementScope, + turns: readonly { identity: AgentJournalItemIdentity; itemId: string }[], + end: ProviderTimelineTurnEnd +): JournalLifecycleMutationInput[] { + const mutations: JournalLifecycleMutationInput[] = [] + journal.visitItemsWithLinkage((itemId, _sequence, body, attribution) => { + const turnScope = attribution.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + const covered = + scope === 'session' || + (turnScope.kind === 'turn' && + turnScope.turnItemId === scope.turnItemId && + !scope.outlivesTurn.has(itemId)) + const settled = covered ? settledProviderTimelineBody(body) : null + const identity = settled ? parseAgentJournalItemKey(itemId) : null + if (settled && identity) { + // No linkage: a revision keeps the row's own producer. + mutations.push({ kind: 'item', identity, body: settled, turnScope }) + } + }) + for (const turn of turns) { + const ended = endedTurnRow(journal, turn, end) + if (ended) { + mutations.push({ kind: 'item', ...ended, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + } + } + return mutations +} + +/** Every turn row the journal holds running, for the session's end. */ +export function runningProviderTimelineTurns( + journal: StructuredAgentSessionTransitionJournal +): { identity: AgentJournalItemIdentity; itemId: string }[] { + const running: { identity: AgentJournalItemIdentity; itemId: string }[] = [] + journal.visitItems((itemId, _sequence, body) => { + const identity = + readAgentJournalTurn(body)?.state === 'running' ? parseAgentJournalItemKey(itemId) : null + if (identity) { + running.push({ identity, itemId }) + } + }) + return running +} + +function endedTurnRow( + journal: StructuredAgentSessionTransitionJournal, + turn: { identity: AgentJournalItemIdentity; itemId: string }, + end: ProviderTimelineTurnEnd +): { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } | null { + const running = readAgentJournalTurn(journal.itemBody(turn.itemId) ?? undefined) + if (running?.state !== 'running') { + return null + } + const target = { identity: turn.identity } + const write = { + lifecycle: agentJournalTurnBody(endedProviderTimelineTurn(running, end)), + onlyWhileRunning: true as const + } + return resolveAgentJournalTurnRowWrite( + journal, + target, + write, + agentJournalTurnRowReservedBytes(target, write) + ) +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts new file mode 100644 index 000000000000..d3daf3ef7056 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -0,0 +1,172 @@ +// What the assembler knows about the session, held twice. +// +// The ledger is the truth the decisions run on: it changes only inside transition resolvers, at +// each event's turn in the journal's write queue, and everything in it is either a cache of the +// journal (re-derived from the fold when missing) or this run's own memory (items it closed, the +// sends waiting for a turn). The forecast is a copy plus what admitted events still in the queue +// are expected to change; it only answers `apply` before those events land, and is rebuilt from +// the ledger as they do. + +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnLifecycle, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { BoundedMap } from '../../../shared/bounded-map' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { ProviderTimelineTurnRef } from './provider-timeline-joins' + +/** Closed items remembered so a repeat close is dropped and an open reopens; past this, the + * journal decides from the row alone. */ +const MAX_CLOSED_ITEMS = 512 +/** Turns remembered as settled without asking the journal. */ +const MAX_SETTLED_TURNS = 256 +/** Sends waiting for a turn; a provider that never opens one cannot grow this without bound. */ +export const MAX_PENDING_INPUTS = 64 + +export type ProviderTimelineOpenTurn = ProviderTimelineTurnRef & { + running: AgentJournalTurnLifecycle +} + +export type ProviderTimelinePendingInput = { clientMessageId: string; requestedAt: number } + +/** Work still waiting on a row that settles it: a running tool, a backgrounded task, a pending + * request. Keyed by the item's join reference (a request's by its key, whatever its incarnation). */ +export type ProviderTimelineObligation = { + /** The row, once a resolver placed it. */ + itemId: string | null + turnItemId: string | null + outlivesTurn: boolean + bytes: number +} + +export type ProviderTimelineTurnStatus = 'absent' | 'running' | 'settled' + +export class ProviderTimelineState { + ended = false + open: ProviderTimelineOpenTurn | null = null + latest: ProviderTimelineTurnRef | null = null + inputs: ProviderTimelinePendingInput[] = [] + /** Items this run closed, with the turn they closed in. */ + closed = new BoundedMap({ maxEntries: MAX_CLOSED_ITEMS }) + settled = new BoundedMap({ maxEntries: MAX_SETTLED_TURNS }) + obligations = new Map() + /** Whether the open turn was taken from the journal yet (ledger only). */ + hydrated = false + + constructor(public namespace: string) {} + + get scope(): AgentJournalTurnScope { + return this.open ? { kind: 'turn', turnItemId: this.open.itemId } : AGENT_JOURNAL_THREAD_SCOPE + } + + clone(): ProviderTimelineState { + const copy = new ProviderTimelineState(this.namespace) + copy.ended = this.ended + copy.open = this.open && { ...this.open } + copy.latest = this.latest + copy.inputs = [...this.inputs] + for (const [key, value] of this.closed.entries()) { + copy.closed.set(key, value) + } + for (const [key, value] of this.settled.entries()) { + copy.settled.set(key, value) + } + copy.obligations = new Map(this.obligations) + copy.hydrated = this.hydrated + return copy + } + + status( + turn: { itemId: string }, + journal: StructuredAgentSessionTransitionJournal | null + ): ProviderTimelineTurnStatus { + if (this.open?.itemId === turn.itemId) { + return 'running' + } + if (this.settled.has(turn.itemId)) { + return 'settled' + } + const row = readAgentJournalTurn(journal?.itemBody(turn.itemId) ?? undefined) + return row === null ? 'absent' : row.state === 'running' ? 'running' : 'settled' + } + + /** The obligations a turn's end leaves open: the ones that outlive it. */ + outliving(turnItemId: string): Set { + const outliving = new Set() + for (const obligation of this.obligations.values()) { + if (obligation.outlivesTurn && obligation.itemId && obligation.turnItemId === turnItemId) { + outliving.add(obligation.itemId) + } + } + return outliving + } + + /** The turn is over, and so is everything its settlement covered. */ + endTurn(turn: ProviderTimelineTurnRef): void { + if (this.open?.itemId === turn.itemId) { + this.open = null + } + this.latest = turn + this.settled.set(turn.itemId, true) + for (const [key, obligation] of this.obligations) { + if (obligation.turnItemId === turn.itemId && !obligation.outlivesTurn) { + this.obligations.delete(key) + } + } + // A settled turn's rows are judged by the turn now. + for (const [key, turnItemId] of this.closed.entries()) { + if (turnItemId === turn.itemId) { + this.closed.delete(key) + } + } + } + + /** The session is over: nothing it held is open any more. */ + endSession(): void { + this.open = null + this.obligations.clear() + this.closed.clear() + } + + reset(namespace: string): void { + this.namespace = namespace + this.ended = false + this.open = null + this.latest = null + this.inputs = [] + this.obligations.clear() + this.closed.clear() + this.settled.clear() + } + + /** Takes the open turn from the journal: the newest turn row it holds running. Its key is the + * turn id the row carries, which is the provider's own turn id for a scheme that spells it. */ + hydrate(journal: StructuredAgentSessionTransitionJournal): void { + this.hydrated = true + if (this.open) { + return + } + let newest = -1 + journal.visitItems((itemId, sequence, body) => { + const running = readAgentJournalTurn(body) + const identity = running?.state === 'running' ? parseAgentJournalItemKey(itemId) : null + if (!running || !identity || sequence < newest) { + return + } + newest = sequence + this.open = { + address: { + namespace: this.namespace, + key: { source: 'provider', value: running.turnId } + }, + identity, + itemId, + turnId: running.turnId, + running + } + }) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts new file mode 100644 index 000000000000..8b793ef2139f --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts @@ -0,0 +1,105 @@ +// Text events: planning-side streams, admitted like every other event. + +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' +import type { + ProviderTimelineApplyHost, + ProviderTimelineApplyResult, + ProviderTimelineInFlight +} from './provider-timeline-apply-host' +import { PROVIDER_TIMELINE_OVER_BUDGET } from './provider-timeline-budget' +import type { ProviderTimelineEvent } from './provider-timeline-event' +import { ProviderTimelinePlan } from './provider-timeline-plan' + +const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +export function applyProviderTimelineTextDelta( + host: ProviderTimelineApplyHost, + event: Extract +): ProviderTimelineApplyResult { + const { streams } = host + const forecast = host.forecast() + if (forecast.ended) { + return { admission: ADMITTED, dropped: 'session-ended' } + } + const journal = host.journal() + const plan = new ProviderTimelinePlan() + let stream = streams.get(streams.key(event.item, event.join, forecast)) + if (stream && !streams.continues(stream, event.channel, event.producer)) { + if (stream.named) { + return { admission: ADMITTED, dropped: 'stream-mismatch' } + } + // The anonymous stream's next message: what the last one owes lands first. + streams.planFlush(plan) + const ended = stream + streams.planRelease(plan, (each) => each === ended) + stream = undefined + } + if (!stream) { + if ('id' in event.item && settledNamedItem(host, event, journal)) { + return { admission: ADMITTED, dropped: 'item-settled' } + } + stream = streams.start({ + item: event.item, + join: event.join, + channel: event.channel, + producer: event.producer, + state: forecast + }) + if (!host.admits({ key: stream.key, bytes: stream.bytes })) { + return { admission: PROVIDER_TIMELINE_OVER_BUDGET } + } + } + streams.planAppend(plan, stream, event.text) + return { admission: host.submit(plan, { executed: false, commit: undefined }) } +} + +/** A named delta for an item this run closed, or one the journal holds in a settled turn. */ +function settledNamedItem( + host: ProviderTimelineApplyHost, + event: Extract, + journal: StructuredAgentSessionTransitionJournal | null +): boolean { + const { context } = host + const forecast = host.forecast() + if (!('id' in event.item)) { + return false + } + const join = { + family: 'item' as const, + key: { source: 'provider' as const, value: event.item.id }, + thread: event.join?.thread ?? null + } + if (forecast.closed.has(context.joins.reference(join, forecast.namespace))) { + return true + } + const row = context.joins.find(join, journal, forecast.namespace) + const turnItemId = row?.scope.kind === 'turn' ? row.scope.turnItemId : null + return turnItemId !== null && forecast.status({ itemId: turnItemId }, journal) === 'settled' +} + +export function applyProviderTimelineTextClose( + host: ProviderTimelineApplyHost, + event: Extract +): ProviderTimelineApplyResult { + const { streams } = host + const forecast = host.forecast() + if (forecast.ended) { + return { admission: ADMITTED, dropped: 'session-ended' } + } + const stream = streams.get(streams.key(event.item, event.join, forecast)) + if (!stream) { + return { admission: ADMITTED, dropped: 'stream-unknown' } + } + const plan = new ProviderTimelinePlan() + streams.planFlush(plan, stream) + streams.planClose(plan, stream, event.text) + const entry: ProviderTimelineInFlight = { + executed: false, + commit: stream.named ? (state) => state.closed.set(stream.key, stream.turnItemId) : undefined + } + plan.atExecution(() => { + entry.executed = true + }) + return { admission: host.submit(plan, entry) } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts new file mode 100644 index 000000000000..90dc99135631 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts @@ -0,0 +1,360 @@ +// Streamed text, as message rows. +// +// A named stream is the item with that id on its thread, so its deltas, its close and any full +// snapshot of it are one row. An anonymous stream becomes a new message each time it starts, and +// its identity is its name, thread, channel and producer: a change to any starts the next +// message. Deltas accumulate in the shared coalescer, and a row is a snapshot of the text so far. +// Text owed to the journal is written inside the next event's transition — every other event is +// an ordering barrier — or by the coalescer's window, and is marked written once the sink takes it. +// +// Which row a stream writes is decided when its first write runs, like every other row: a +// provider-named message the journal already holds is a message this run is resuming (its turn +// still running: the stream adopts the row's text as its prefix) or a replay (its turn settled, or +// this run closed it: nothing is written). + +import type { + AgentJournalItemBody, + AgentJournalProducerLinkage +} from '../../../shared/agent-session-journal-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../agent-session-journal/journal-payload-bounds' +import { + createAgentSessionDeltaCoalescer, + type AgentSessionDeltaCoalescerDeps +} from '../agent-session-wire/agent-session-delta-coalescer' +import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' +import type { + StructuredAgentSessionItemAppendOptions, + StructuredAgentSessionSinkAdmission +} from '../agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { + providerTimelineEntryBytes, + providerTimelineLedger, + providerTimelinePlacement, + type ProviderTimelineContext +} from './provider-timeline-context' +import type { + ProviderTimelineJoin, + ProviderTimelineTextChannel, + ProviderTimelineTextItem +} from './provider-timeline-event' +import { providerTimelineKeyPart } from './provider-timeline-identity' +import type { ProviderTimelineItemJoin, ProviderTimelineRow } from './provider-timeline-joins' +import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' +import type { ProviderTimelineState } from './provider-timeline-state' + +/** One open text stream, as planning knows it. */ +export type ProviderTimelineStream = { + /** Unique per stream: the coalescer's key, and the ledger's for the row it owns. */ + id: string + /** The stream's slot: a named stream's is its item's join reference, which the budget shares. */ + key: string + join: ProviderTimelineItemJoin + /** The turn the provider named, if any. */ + turn: string | undefined + /** The turn planning expects its row in, so a turn's end releases it. */ + turnItemId: string | null + named: boolean + channel: ProviderTimelineTextChannel + producer: AgentJournalProducerLinkage | undefined + /** Whether any text was written; a whitespace-only stream completes nothing. */ + written: boolean + bytes: number +} + +/** The row a stream writes, and the text it held before this run streamed into it. */ +type Owned = { row: ProviderTimelineRow; prefix: string } | 'replayed' + +type Resolved = { + identity: ProviderTimelineRow['identity'] + body: AgentJournalItemBody + options: StructuredAgentSessionItemAppendOptions +} + +export function providerTimelineMessageText(body: AgentJournalItemBody | null): string { + return body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' +} + +export class ProviderTimelineTextStreams { + private readonly streams = new Map() + private readonly byId = new Map() + /** Ledger: the row each stream writes, decided by its first write. */ + private readonly owned = new Map() + private readonly coalescer + private serial = 0 + + constructor( + private readonly deps: { + sink: ProviderTimelineSink + context: ProviderTimelineContext + generation: string + coalesceMs?: number + schedule?: AgentSessionDeltaCoalescerDeps['schedule'] + } + ) { + this.coalescer = createAgentSessionDeltaCoalescer({ + ...(deps.coalesceMs === undefined ? {} : { windowMs: deps.coalesceMs }), + ...(deps.schedule ? { schedule: deps.schedule } : {}), + // Every stream here is one this assembler holds open; the shared budget counts them. + isProtected: () => true, + emit: (id, text) => this.flushOnWindow(id, text) + }) + } + + /** What the budget counts: one entry per open stream. */ + get open(): readonly ProviderTimelineStream[] { + return [...this.streams.values()] + } + + /** The stream slot `item` names on its thread. */ + key( + item: ProviderTimelineTextItem, + join: ProviderTimelineJoin | undefined, + state: ProviderTimelineState + ): string { + return 'id' in item + ? this.deps.context.joins.reference(this.itemJoin(item, join), state.namespace) + : `stream:${providerTimelineKeyPart(join?.thread ?? '')}:${providerTimelineKeyPart(item.stream)}` + } + + get(key: string): ProviderTimelineStream | undefined { + return this.streams.get(key) + } + + /** A new stream; the caller has checked the budget for its `bytes`. */ + start(input: { + item: ProviderTimelineTextItem + join: ProviderTimelineJoin | undefined + channel: ProviderTimelineTextChannel + producer: AgentJournalProducerLinkage | undefined + state: ProviderTimelineState + }): ProviderTimelineStream { + this.serial += 1 + const named = 'id' in input.item + const join = named ? this.itemJoin(input.item, input.join) : this.anonymousJoin(input.join) + const placement = providerTimelinePlacement(this.deps.context, input.state, input.join) + return { + id: `${this.deps.generation}:s${this.serial}`, + key: this.key(input.item, input.join, input.state), + join, + turn: input.join?.turn, + turnItemId: placement.scope.kind === 'turn' ? placement.scope.turnItemId : null, + named, + channel: input.channel, + producer: input.producer, + written: false, + bytes: providerTimelineEntryBytes( + 'id' in input.item ? input.item.id : input.item.stream, + undefined, + input.producer + ) + } + } + + /** Whether a delta on `stream` continues it or begins another message. */ + continues( + stream: ProviderTimelineStream, + channel: ProviderTimelineTextChannel, + producer: AgentJournalProducerLinkage | undefined + ): boolean { + return stream.channel === channel && stream.producer?.agentId === producer?.agentId + } + + /** The delta's text; the stream opens when the event is admitted. */ + planAppend(plan: ProviderTimelinePlan, stream: ProviderTimelineStream, text: string): void { + plan.onAdmitted(() => { + if (!this.byId.has(stream.id)) { + this.streams.set(stream.key, stream) + this.byId.set(stream.id, stream) + } + this.coalescer.append(stream.id, text) + }) + } + + /** Every stream's unwritten text, ahead of whatever the event writes. */ + planFlush(plan: ProviderTimelinePlan, except?: ProviderTimelineStream): void { + for (const { key, snapshot } of this.coalescer.dirty()) { + const stream = this.byId.get(key) + if (stream && stream !== except) { + this.planText(plan, stream, snapshot.text) + } + } + } + + /** Ends the streams `which` selects; their text was flushed by the same event. */ + planRelease( + plan: ProviderTimelinePlan, + which: (stream: ProviderTimelineStream) => boolean + ): void { + const released = [...this.streams.values()].filter(which) + if (released.length === 0) { + return + } + plan.onAdmitted(() => released.forEach((stream) => this.forget(stream))) + plan.atExecution(() => released.forEach((stream) => this.owned.delete(stream.id))) + } + + /** Ends one stream with the provider's final text, else what streamed; it settles the item. */ + planClose(plan: ProviderTimelinePlan, stream: ProviderTimelineStream, finalText?: string): void { + if (finalText === undefined) { + const owed = this.coalescer.dirty().find(({ key }) => key === stream.id) + if (owed) { + this.planText(plan, stream, owed.snapshot.text) + } + } else { + this.planText( + plan, + stream, + boundInlineText(finalText, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text, + true + ) + } + this.planRelease(plan, (each) => each === stream) + if (stream.named) { + // The close settles the item for full snapshots too. + plan.atExecution(() => { + const owned = this.owned.get(stream.id) + const row = owned && owned !== 'replayed' ? owned.row : null + this.deps.context.ledger.closed.set( + stream.key, + row?.scope.kind === 'turn' ? row.scope.turnItemId : null + ) + }) + } + } + + flush(): void { + this.coalescer.flushAll() + } + + dispose(): void { + this.open.forEach((stream) => this.forget(stream)) + this.owned.clear() + this.coalescer.dispose() + } + + /** The window's flush: false keeps the text for a retry, but only while the sink is merely full. */ + private flushOnWindow(id: string, text: string): boolean { + const stream = this.byId.get(id) + if (!stream) { + return true + } + const plan = new ProviderTimelinePlan() + this.planText(plan, stream, text) + const admission: StructuredAgentSessionSinkAdmission = plan.submit(this.deps.sink) + // A failed or closed sink can never take it; the session's end owns what it leaves. + return admission.accepted || admission.reason !== 'backpressure' + } + + private planText( + plan: ProviderTimelinePlan, + stream: ProviderTimelineStream, + text: string, + providerFinal = false + ): void { + const empty = providerFinal ? text.length === 0 : text.trim().length === 0 + if (!stream.written && empty) { + plan.onAdmitted(() => this.coalescer.markFlushed(stream.id)) + return + } + const placeholder = this.message(stream, text) + plan.item({ + // Room for the text and the longest identity a scheme spells; a resumed prefix only paces. + reservedBytes: estimateStructuredAgentSessionItemBytes( + { provider: 'orca', clientMessageId: 'x'.repeat(1024) }, + placeholder + ), + paced: true, + resolve: (journal) => this.resolveText(stream, text, providerFinal, journal), + options: { ...stream.producer, turnScope: { kind: 'thread' } } + }) + plan.onAdmitted(() => { + stream.written = true + this.coalescer.markFlushed(stream.id) + }) + } + + /** At the write's turn in the queue: the row the stream owns, and its text so far. */ + private resolveText( + stream: ProviderTimelineStream, + text: string, + providerFinal: boolean, + journal: StructuredAgentSessionTransitionJournal + ): Resolved | null { + const ledger = providerTimelineLedger(this.deps.context, journal) + let owned = this.owned.get(stream.id) + if (!owned) { + owned = this.own(stream, ledger, journal) + this.owned.set(stream.id, owned) + } + if (owned === 'replayed' || ledger.ended) { + return null + } + const { row, prefix } = owned + return { + identity: row.identity, + body: this.message(stream, providerFinal ? text : `${prefix}${text}`), + options: { + ...stream.producer, + turnScope: row.scope, + ...(row.ref === undefined ? {} : { providerItemRef: row.ref }) + } + } + } + + private own( + stream: ProviderTimelineStream, + ledger: ProviderTimelineState, + journal: StructuredAgentSessionTransitionJournal + ): Owned { + const { joins } = this.deps.context + if (stream.named && ledger.closed.has(stream.key)) { + return 'replayed' + } + const found = stream.named ? joins.find(stream.join, journal) : null + if (found) { + const turnItemId = found.scope.kind === 'turn' ? found.scope.turnItemId : null + if (turnItemId !== null && ledger.status({ itemId: turnItemId }, journal) === 'settled') { + return 'replayed' + } + return { row: found, prefix: providerTimelineMessageText(journal.itemBody(found.itemId)) } + } + const placement = providerTimelinePlacement(this.deps.context, ledger, { + ...(stream.join.thread === null ? {} : { thread: stream.join.thread }), + ...(stream.turn === undefined ? {} : { turn: stream.turn }) + }) + const itemClass = stream.channel === 'assistant' ? 'message' : 'reasoning' + return { row: joins.place(stream.join, itemClass, placement, journal), prefix: '' } + } + + private message(stream: ProviderTimelineStream, text: string): AgentJournalItemBody { + return { + kind: 'message', + role: stream.channel === 'assistant' ? 'assistant' : 'reasoning', + blocks: [{ type: 'text', text }] + } + } + + private itemJoin(item: { id: string }, join?: ProviderTimelineJoin): ProviderTimelineItemJoin { + return { + family: 'item', + key: { source: 'provider', value: item.id }, + thread: join?.thread ?? null + } + } + + private anonymousJoin(join?: ProviderTimelineJoin): ProviderTimelineItemJoin { + return { family: 'item', key: this.deps.context.joins.mint('s'), thread: join?.thread ?? null } + } + + private forget(stream: ProviderTimelineStream): void { + this.coalescer.forget(stream.id) + if (this.streams.get(stream.key) === stream) { + this.streams.delete(stream.key) + } + this.byId.delete(stream.id) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts new file mode 100644 index 000000000000..726070438901 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts @@ -0,0 +1,120 @@ +// The writes one non-text event may need, laid out before anything is decided: the text owed +// ahead of it, then its fixed settlement and item slots, each resolved when it runs. + +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { unhandledProviderFrameJournalItem } from '../agent-session-wire/unhandled-provider-frame' +import type { ProviderTimelineApplyHost } from './provider-timeline-apply-host' +import type { + ProviderTimelineDecidedEvent, + ProviderTimelineDecision +} from './provider-timeline-decisions' +import type { ProviderTimelinePlan } from './provider-timeline-plan' + +/** Room for a settled turn row and its context facts. */ +const TURN_ROW_RESERVED_BYTES = 64 * 1024 +/** Identities a scheme spells stay well under this; item rows reserve it for theirs. */ +const IDENTITY_PLACEHOLDER = { provider: 'orca', clientMessageId: 'x'.repeat(1024) } as const + +/** Text owed ahead of the event lands first; a row-writing event also ends the messages it + * separates: anonymous ones of its producer, every stream of a turn it ends, all on a session end. */ +export function planProviderTimelineBarrier( + host: ProviderTimelineApplyHost, + plan: ProviderTimelinePlan, + event: ProviderTimelineDecidedEvent, + decision: ProviderTimelineDecision +): void { + const { streams, context } = host + const forecast = host.forecast() + // A full snapshot of a streamed message replaces what streamed, so that text is not flushed. + const replaced = decision.closes ? streams.get(decision.closes) : undefined + streams.planFlush(plan, replaced) + if (replaced) { + streams.planRelease(plan, (stream) => stream === replaced) + } + if (event.type === 'session.ended' || event.type === 'session.reset') { + streams.planRelease(plan, () => true) + return + } + if (event.type === 'turn.end' || event.type === 'turn.open') { + const ending = + event.type === 'turn.end' && event.turn !== undefined + ? context.joins.turn({ source: 'provider', value: event.turn }, forecast.namespace).itemId + : forecast.open?.itemId + streams.planRelease( + plan, + (stream) => + (!stream.named && stream.producer?.agentId === undefined) || + (ending !== undefined && stream.turnItemId === ending) + ) + return + } + if (event.type !== 'input.accepted') { + const agentId = 'producer' in event ? event.producer?.agentId : undefined + streams.planRelease(plan, (stream) => !stream.named && stream.producer?.agentId === agentId) + } +} + +export function planProviderTimelineSlots( + host: ProviderTimelineApplyHost, + plan: ProviderTimelinePlan, + event: ProviderTimelineDecidedEvent, + decide: (journal: StructuredAgentSessionTransitionJournal) => ProviderTimelineDecision | null +): void { + const { context } = host + const settlement = (what: string) => + plan.settlement({ + settlementId: context.settlementId(what), + reservedBytes: TURN_ROW_RESERVED_BYTES, + resolve: (journal) => decide(journal)?.settle ?? [] + }) + const item = (reservedBytes: number, lifecycle: boolean) => + plan.item( + { + reservedBytes, + resolve: (journal) => decide(journal)?.write ?? null, + options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + }, + lifecycle + ) + switch (event.type) { + case 'turn.open': + settlement('turn-superseded') + item(TURN_ROW_RESERVED_BYTES, true) + return + case 'turn.end': + settlement('turn-end') + return + case 'request.withdrawn': + settlement('request-withdrawn') + return + case 'session.ended': + settlement('session-end') + return + case 'session.reset': + settlement('session-reset') + return + case 'input.accepted': + case 'context.usage': + item(TURN_ROW_RESERVED_BYTES, true) + return + case 'item.open': + case 'item.update': + case 'item.close': + item( + estimateStructuredAgentSessionItemBytes(IDENTITY_PLACEHOLDER, event.body), + event.type !== 'item.update' + ) + return + case 'request.open': + item(estimateStructuredAgentSessionItemBytes(IDENTITY_PLACEHOLDER, event.body), true) + return + case 'provider.frame': { + const frame = unhandledProviderFrameJournalItem(context.agent, event.frameKind, event.payload) + if (frame) { + item(estimateStructuredAgentSessionItemBytes(IDENTITY_PLACEHOLDER, frame.body), false) + } + } + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts new file mode 100644 index 000000000000..2ae960936e0c --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts @@ -0,0 +1,235 @@ +// What turn, send, context and session events do, decided on the forecast and again on the ledger. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + agentJournalTurnBody, + readAgentJournalTurn +} from '../../../shared/agent-session-turn-record' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { + agentJournalTurnRowReservedBytes, + resolveAgentJournalTurnRowWrite +} from './agent-journal-turn-row-revision' +import { + providerKey, + settlementOf, + type ProviderTimelineDecidedEvent, + type ProviderTimelineDecision, + type ProviderTimelineDecisionInput, + type ProviderTimelineResolvedWrite +} from './provider-timeline-decision' +import type { ProviderTimelineTurnRef } from './provider-timeline-joins' +import { + providerTimelineSettlement, + runningProviderTimelineTurns, + type ProviderTimelineTurnEnd +} from './provider-timeline-settlement' +import { MAX_PENDING_INPUTS, type ProviderTimelineState } from './provider-timeline-state' + +export function decideTurnOpen( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, context } = input + const open = state.open + const key = event.turn === undefined ? event.minted : providerKey(event.turn) + if (!key || (event.turn === undefined && open)) { + return { dropped: 'turn-duplicate' } + } + const turn = context.joins.turn(key, state.namespace) + if (open?.itemId === turn.itemId) { + return { dropped: 'turn-duplicate' } + } + if (state.status(turn, input.journal) !== 'absent') { + return { dropped: 'turn-replayed' } + } + const pending = state.inputs[0] + const running: AgentJournalTurnLifecycle = { + turnId: turn.turnId, + state: 'running', + userItemId: pending + ? agentJournalSubmissionKey(pending.clientMessageId) + : context.joins.turnOpener(turn), + startedAt: event.at, + ...(pending ? { requestedAt: pending.requestedAt } : {}) + } + // A newer turn ended this one, whoever asked for it. + const superseded = open + ? settlementOf(input, open, { + state: 'interrupted', + completedAt: event.at, + outcome: 'superseded' + }) + : [] + return { + ...(superseded ? { settle: superseded } : {}), + write: input.execute + ? { + identity: turn.identity, + body: agentJournalTurnBody(running), + // The running row's ts is the turn start itself, so clients read no append lag. + options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true, observedAt: event.at } + } + : null, + commit: (next) => { + if (next.open) { + next.endTurn(next.open) + } + if (pending) { + next.inputs.shift() + } + next.open = { ...turn, running } + } + } +} + +export function decideTurnEnd( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state } = input + const turn = + event.turn === undefined + ? state.open + : input.context.joins.turn(providerKey(event.turn), state.namespace) + if (!turn) { + return { dropped: 'no-turn' } + } + if (state.status(turn, input.journal) !== 'running') { + return { dropped: 'turn-unknown' } + } + const settle = settlementOf(input, turn, { + state: event.state, + completedAt: event.at, + ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), + ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) + }) + return { ...(settle ? { settle } : {}), commit: (next) => next.endTurn(turn) } +} + +export function decideInput( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal, context } = input + const named = event.join?.turn + const turnKey = named === undefined ? (state.open?.address.key ?? null) : providerKey(named) + if (input.execute && journal && turnKey && event.join?.item !== undefined) { + context.joins.reserveEcho( + { family: 'item', key: providerKey(event.join.item), thread: event.join.thread ?? null }, + { thread: event.join.thread ?? null, turn: turnKey, scope: state.scope }, + journal + ) + } + const open = state.open + // A late echo of a turn already over names nothing. + if ( + named !== undefined && + context.joins.turn(providerKey(named), state.namespace).itemId !== open?.itemId + ) { + return {} + } + const pending = { clientMessageId: event.clientMessageId, requestedAt: event.requestedAt } + if (!open) { + return { + commit: (next) => { + next.inputs.push(pending) + next.inputs.splice(0, Math.max(0, next.inputs.length - MAX_PENDING_INPUTS)) + } + } + } + if (open.running.userItemId !== context.joins.turnOpener(open)) { + return {} + } + const userItemId = agentJournalSubmissionKey(event.clientMessageId) + const running = { ...open.running, userItemId, requestedAt: event.requestedAt } + return { + write: + input.execute && journal ? reviseOpener(journal, open, userItemId, event.requestedAt) : null, + commit: (next) => { + if (next.open?.itemId === open.itemId) { + next.open = { ...next.open, running } + } + } + } +} + +/** Only the opener fields change; the rest are the row's as the journal holds it. */ + +/** Only the opener fields change; the rest are the row's as the journal holds it. */ +export function reviseOpener( + journal: StructuredAgentSessionTransitionJournal, + open: ProviderTimelineTurnRef, + userItemId: string, + requestedAt: number +): ProviderTimelineResolvedWrite | null { + const row = readAgentJournalTurn(journal.itemBody(open.itemId) ?? undefined) + if (row?.state !== 'running') { + return null + } + const target = { identity: open.identity } + const write = { + lifecycle: agentJournalTurnBody({ ...row, userItemId, requestedAt }), + onlyWhileRunning: true as const + } + const resolved = resolveAgentJournalTurnRowWrite( + journal, + target, + write, + agentJournalTurnRowReservedBytes(target, write) + ) + return ( + resolved && { ...resolved, options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } } + ) +} + +export function decideSessionEnd( + input: ProviderTimelineDecisionInput, + end: ProviderTimelineTurnEnd, + commit: (state: ProviderTimelineState) => void +): ProviderTimelineDecision { + const { journal } = input + return { + ...(input.execute && journal + ? { + settle: providerTimelineSettlement( + journal, + 'session', + runningProviderTimelineTurns(journal), + end + ) + } + : {}), + commit + } +} + +export function decideContextUsage( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal } = input + const named = event.join?.turn + const turn = + named === undefined + ? (state.open ?? state.latest) + : input.context.joins.turn(providerKey(named), state.namespace) + if (!input.execute || !journal) { + return {} + } + const target = turn ? { identity: turn.identity } : ({ newest: true } as const) + const write = { contextUsage: event.usage } + const resolved = resolveAgentJournalTurnRowWrite( + journal, + target, + write, + agentJournalTurnRowReservedBytes(target, write) + ) + return { + write: resolved && { ...resolved, options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE } } + } +} From 9408e432e69a150a7eb979cf4011be9b5e1d4ae1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:03:18 -0700 Subject: [PATCH 040/269] Fix the types and the exhaustive event switch CI reported for the assembler --- .../provider-timeline-assembler.ts | 13 +++++++++++-- .../provider-timeline-text-streams.ts | 3 ++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts index 480e40c88bac..4fcb4459190a 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -127,7 +127,7 @@ export function createProviderTimelineAssembler( const submit = ( plan: ProviderTimelinePlan, - entry: InFlight + entry: ProviderTimelineInFlight ): StructuredAgentSessionSinkAdmission => { if (!plan.writes) { return plan.submit(deps.sink) @@ -234,7 +234,16 @@ export function createProviderTimelineAssembler( ) case 'provider.frame': return applyDecided({ ...event, minted: context.joins.mint('f') }) - default: + case 'input.accepted': + case 'turn.end': + case 'item.open': + case 'item.update': + case 'item.close': + case 'request.open': + case 'request.withdrawn': + case 'context.usage': + case 'session.ended': + case 'session.reset': return applyDecided(event) } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts index 90dc99135631..27c663b4239b 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts @@ -134,7 +134,8 @@ export class ProviderTimelineTextStreams { }): ProviderTimelineStream { this.serial += 1 const named = 'id' in input.item - const join = named ? this.itemJoin(input.item, input.join) : this.anonymousJoin(input.join) + const join = + 'id' in input.item ? this.itemJoin(input.item, input.join) : this.anonymousJoin(input.join) const placement = providerTimelinePlacement(this.deps.context, input.state, input.join) return { id: `${this.deps.generation}:s${this.serial}`, From c491688b29ed80939476f99af40df40b34b3d22d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:15:06 -0700 Subject: [PATCH 041/269] Let the journal decide stream lifetimes, request reuse, named sends and background work A third review found two blockers with the earlier rounds' cause, a remembered interpretation trusted after the journal moved on: - A reused request id was judged by its earlier prompt's settled turn before asking which turn the new one lands in, so a real approval in a later turn was dropped. The target turn now decides: the old turn again is a replay; a different live turn opens the next prompt beside it. - A text stream checked its row's turn only on its first write, and a turn's end released streams by the turn planning expected. Every write now checks the row, a turn's end stops the streams whose rows are in it, and turn status reads the journal first, so another writer's Stop wins. Also: a message boundary drawn by an event the journal held as a replay no longer splits an anonymous message; a send naming a turn not yet open waits for that turn; the budget charges a stream's thread and turn strings and the turn caches are byte-bounded; the open turn ends when the journal shows it settled; a turn's opener is read from the journal's row. Background work is now Orca's existing background-task row instead of a tool call flagged `outlivesTurn` (a flag remembered only in memory, so a restart failed the task). A turn's end never settles that row, so it survives restarts; session end leaves one in flight unverifiable. Three tests that opened a background tool call with `outlivesTurn` now open a background-task row and keep their original expectations about which turn the row stays in. --- ...der-timeline-assembler-attribution.test.ts | 265 ++++++++++++++++++ .../provider-timeline-assembler-items.test.ts | 24 +- .../provider-timeline-assembler-order.test.ts | 19 +- ...ovider-timeline-assembler-recovery.test.ts | 8 +- ...rovider-timeline-assembler-streams.test.ts | 177 ++++++++++++ ...rovider-timeline-assembler-test-support.ts | 75 +++++ .../provider-timeline-assembler.ts | 8 +- .../provider-timeline-background-tasks.ts | 68 +++++ .../provider-timeline-context.ts | 28 +- .../provider-timeline-decision.ts | 3 +- .../provider-timeline-event.ts | 13 +- .../provider-timeline-item-decisions.ts | 50 ++-- .../provider-timeline-settlement.ts | 15 +- .../provider-timeline-state.ts | 78 ++++-- .../provider-timeline-stream-rows.ts | 168 +++++++++++ .../provider-timeline-text-streams.ts | 123 ++++---- .../provider-timeline-transition-layout.ts | 20 +- .../provider-timeline-turn-decisions.ts | 45 +-- 18 files changed, 997 insertions(+), 190 deletions(-) create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts new file mode 100644 index 000000000000..92238e697090 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts @@ -0,0 +1,265 @@ +// Where a request, a send or a background task belongs is decided from the turn the event names +// and the journal as it stands, not from what an earlier event left in memory. + +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' +import { createCodexProviderTimelineIdentityScheme } from '../../codex/codex-provider-timeline-identity' +import { + assistantText, + backgroundTask, + backgroundTaskState, + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + openUnboundProviderTimelineAssembler, + pendingApproval, + providerItemId, + providerTurnItemId, + SESSION, + type ProviderTimelineRig +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const answered = { + ...pendingApproval, + resolution: { + state: 'resolved' as const, + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 1_500 + } +} + +function identityOf(itemId: string) { + const identity = parseAgentJournalItemKey(itemId) + if (!identity) { + throw new Error(`${itemId} did not parse`) + } + return identity +} + +/** `p1` opened in `t1` and answered by a client, `t1` over, `t2` running. */ +async function answeredInAnEarlierTurn(): Promise { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + await rig.journal.appendItem(identityOf(providerItemId('request', 'p1')), answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + await rig.rows() + return rig +} + +const second = providerItemId('request', 'p1', { incarnation: 2 }) + +describe('a request key reused in another turn', () => { + it('opens a new prompt in the live turn it names after a withdrawn one', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + await rig.rows() + const opened = rig.assembler.apply({ + type: 'request.open', + request: 'p1', + body: pendingApproval, + join: { turn: 't2' } + }) + expect(opened.dropped).toBeUndefined() + expect(await rig.row(second)).toMatchObject({ + body: { resolution: { state: 'pending' } }, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t2') } + }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + }) + + it('opens a new prompt in the open turn after an answered one, leaving the answer', async () => { + const rig = await answeredInAnEarlierTurn() + expect( + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }).dropped + ).toBeUndefined() + expect((await rig.row(second))?.body).toMatchObject({ resolution: { state: 'pending' } }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow' } + }) + }) + + it('is a replay when it names the turn that is over', async () => { + const rig = await answeredInAnEarlierTurn() + expect( + rig.assembler.apply({ + type: 'request.open', + request: 'p1', + body: pendingApproval, + join: { turn: 't1' } + }).dropped + ).toBe('request-replayed') + expect(await rig.row(second)).toBeUndefined() + }) + + it('decides at execution: queued before bind, a replay of the old turn writes nothing', async () => { + const rig = await answeredInAnEarlierTurn() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + const join = { turn: 't1' } + expect( + assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval, join }).dropped + ).toBeUndefined() + await bind() + expect(await rig.row(second)).toBeUndefined() + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved' } + }) + }) + + it('decides at execution: queued before bind, a new prompt in the live turn opens', async () => { + const rig = await answeredInAnEarlierTurn() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + const join = { turn: 't2' } + assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval, join }) + await bind() + expect((await rig.row(second))?.body).toMatchObject({ resolution: { state: 'pending' } }) + }) + + it('decides at execution: a turn another writer ended while it was queued takes no prompt', async () => { + const rig = await answeredInAnEarlierTurn() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + const join = { turn: 't2' } + assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval, join }) + const running = await rig.turn('t2') + if (!running) { + throw new Error('t2 is running') + } + await rig.journal.appendItem( + identityOf(providerTurnItemId('t2')), + agentJournalTurnBody({ ...running, state: 'interrupted', completedAt: 4_000 }), + { fence: 1, turnScope: { kind: 'thread' } } + ) + await bind() + expect(await rig.row(second)).toBeUndefined() + }) +}) + +describe('a send that names its turn', () => { + it('opens the turn it names when that turn opens later', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ + type: 'input.accepted', + clientMessageId: 'send1', + requestedAt: 900, + join: { turn: 't1' } + }) + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + expect(await rig.turn('t1')).toMatchObject({ userItemId: 'orca:send1', requestedAt: 900 }) + }) + + it('never replaces an opener another writer gave the running turn', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + const running = await rig.turn('t1') + if (!running) { + throw new Error('t1 is running') + } + await rig.journal.appendItem( + identityOf(providerTurnItemId('t1')), + agentJournalTurnBody({ ...running, userItemId: 'orca:first', requestedAt: 800 }), + { fence: 1, turnScope: { kind: 'thread' } } + ) + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'later', requestedAt: 900 }) + expect(await rig.turn('t1')).toMatchObject({ userItemId: 'orca:first', requestedAt: 800 }) + }) + + it('waits for its own turn rather than the next one to open', async () => { + const rig = await openProviderTimelineRig() + for (const [send, turn] of [ + ['send-b', 't2'], + ['send-a', 't1'] + ] as const) { + rig.assembler.apply({ + type: 'input.accepted', + clientMessageId: send, + requestedAt: 900, + join: { turn } + }) + } + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't3', at: 3_000 }) + rig.assembler.apply({ type: 'turn.end', at: 4_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 5_000 }) + expect((await rig.turn('t1'))?.userItemId).toBe('orca:send-a') + // A turn no send named opens as the provider's own. + expect((await rig.turn('t3'))?.userItemId).toBe(providerTurnItemId('t3')) + expect((await rig.turn('t2'))?.userItemId).toBe('orca:send-b') + }) +}) + +describe('a background task', () => { + it('outlives its turn after a restart, and settles on its own update', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'bg', body: backgroundTask('bg', 'working') }) + await rig.rows() + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + expect(await backgroundTaskState(rig, 'bg')).toBe('working') + expect( + restarted.apply({ type: 'item.close', item: 'bg', body: backgroundTask('bg', 'done') }) + .dropped + ).toBeUndefined() + expect(await backgroundTaskState(rig, 'bg')).toBe('done') + // A straggler progress report never re-lights it. + expect( + restarted.apply({ type: 'item.update', item: 'bg', body: backgroundTask('bg', 'working') }) + .dropped + ).toBe('item-settled') + expect(await backgroundTaskState(rig, 'bg')).toBe('done') + }) + + it('is left unverifiable, not exited, when the session ends with it in flight', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'bg', body: backgroundTask('bg', 'working') }) + rig.assembler.apply({ type: 'item.open', item: 'ran', body: backgroundTask('ran', 'working') }) + rig.assembler.apply({ type: 'item.close', item: 'ran', body: backgroundTask('ran', 'done') }) + rig.assembler.apply({ type: 'session.ended', verdict: { state: 'unverifiable' } }) + expect((await rig.row(providerItemId('item', 'bg')))?.body).toEqual( + backgroundTask('bg', 'unverifiable') + ) + expect(await backgroundTaskState(rig, 'ran')).toBe('done') + }) +}) + +describe('message places after a restart', () => { + it('continues past the highest place the journal holds, not the first gap', async () => { + const rig = await openProviderTimelineRig({ + scheme: createCodexProviderTimelineIdentityScheme({ + sessionId: SESSION, + primaryThreadId: () => 'root' + }), + ownThread: () => 'root' + }) + await rig.journal.appendItem( + { provider: 'codex', threadId: 'root', turnId: 't1', ordinal: 2 }, + assistantText('old'), + { fence: 1, turnScope: { kind: 'thread' }, providerItemRef: 'item:old' } + ) + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ + type: 'item.close', + item: 'new', + body: assistantText('new'), + join: { thread: 'root', turn: 't1' } + }) + const placed = (await rig.rows()).find((row) => messageText(row.body) === 'new') + expect(placed?.itemId).toBe('codex:root:t1:3') + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts index 0172719fef48..7d6a12178a11 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts @@ -5,6 +5,8 @@ import type { } from '../../../shared/agent-session-journal-types' import { assistantText, + backgroundTask, + backgroundTaskState, closeProviderTimelineRigs, openProviderTimelineRig, providerItemId, @@ -133,15 +135,14 @@ describe('provider timeline items', () => { expect(rows[0]?.turnScope).toEqual({ kind: 'thread' }) }) - it('fails a tool call its turn left running, and keeps one that outlives the turn open', async () => { + it('fails a tool call its turn left running, and leaves a background task it started running', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) rig.assembler.apply({ type: 'item.open', item: 'bg-1', - body: runningTool('background'), - outlivesTurn: true + body: backgroundTask('bg-1', 'working') }) rig.assembler.apply({ type: 'turn.end', @@ -152,19 +153,12 @@ describe('provider timeline items', () => { expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ state: 'failed' }) - expect((await rig.row(providerItemId('item', 'bg-1')))?.body).toMatchObject({ - state: 'running' - }) + expect(await backgroundTaskState(rig, 'bg-1')).toBe('working') - // It settles on its own close later, still scoped to the turn that started it. - rig.assembler.apply({ - type: 'item.close', - item: 'bg-1', - body: { ...runningTool('background'), state: 'completed' } - }) - const background = await rig.row(providerItemId('item', 'bg-1')) - expect(background?.body).toMatchObject({ state: 'completed' }) - expect(background?.turnScope).toEqual({ + // It settles on its own update later, still in the turn that started it. + rig.assembler.apply({ type: 'item.close', item: 'bg-1', body: backgroundTask('bg-1', 'done') }) + expect(await backgroundTaskState(rig, 'bg-1')).toBe('done') + expect((await rig.row(providerItemId('item', 'bg-1')))?.turnScope).toEqual({ kind: 'turn', turnItemId: providerTurnItemId('turn-1') }) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts index 3c8ee4949fdf..9c7be28bd349 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { MAX_PROVIDER_TIMELINE_OPEN_ENTRIES } from './provider-timeline-budget' import { assistantText, + backgroundTask, + backgroundTaskState, closeProviderTimelineRigs, messageText, openProviderTimelineRig, @@ -217,25 +219,16 @@ describe('explicit provider attribution', () => { }) }) - it('keeps an item in the turn it opened in when its close arrives after the next turn opened', async () => { + it('keeps a background task in the turn it opened in when it settles after the next turn opened', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ - type: 'item.open', - item: 'bg', - body: runningTool('task'), - outlivesTurn: true - }) + rig.assembler.apply({ type: 'item.open', item: 'bg', body: backgroundTask('bg', 'working') }) rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) - rig.assembler.apply({ - type: 'item.close', - item: 'bg', - body: { ...runningTool('task'), state: 'completed' } - }) + rig.assembler.apply({ type: 'item.close', item: 'bg', body: backgroundTask('bg', 'done') }) const row = await rig.row(providerItemId('item', 'bg')) expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId: providerTurnItemId('t1') }) - expect(row?.body).toMatchObject({ state: 'completed' }) + expect(await backgroundTaskState(rig, 'bg')).toBe('done') }) it('writes context facts onto the turn the provider names, not the open one', async () => { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts index d1bcda1b390c..b63560978336 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts @@ -10,6 +10,7 @@ import { createProviderTimelineAssembler } from './provider-timeline-assembler' import { AGENT, assistantText, + backgroundTask, closeProviderTimelineRigs, messageText, NAMESPACE, @@ -119,14 +120,13 @@ describe('a forgotten join finds its original row', () => { expect(old.map((row) => row.itemId)).toEqual(['codex:root:t1:0']) }) - it('keeps a late tool close in the turn it opened in after its join was evicted', async () => { + it('keeps a late background task update in the turn it opened in after its join was evicted', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) rig.assembler.apply({ type: 'item.open', item: 'background-tool', - body: runningTool('background'), - outlivesTurn: true + body: backgroundTask('background-tool', 'working') }) rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) @@ -139,7 +139,7 @@ describe('a forgotten join finds its original row', () => { rig.assembler.apply({ type: 'item.close', item: 'background-tool', - body: { ...runningTool('background'), state: 'completed' } + body: backgroundTask('background-tool', 'done') }) expect((await rig.row(providerItemId('item', 'background-tool')))?.turnScope).toEqual({ diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts new file mode 100644 index 000000000000..d2094d9a4399 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts @@ -0,0 +1,177 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + openUnboundProviderTimelineAssembler, + providerItemId, + providerTurnItemId, + type ProviderTimelineRig +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +/** A journal that already holds a settled `old` turn and a running `live` one. */ +async function oldAndLiveTurns(): Promise { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', turn: 'old', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) + await rig.rows() + return rig +} + +async function messages(rig: ProviderTimelineRig): Promise<(string | undefined)[]> { + return (await rig.rows()) + .filter((row) => row.body.kind === 'message') + .map((row) => messageText(row.body)) +} + +describe('a stream follows the row it resolved to, not the one planning expected', () => { + it('stops at the end of the turn its row landed in, though planning expected another', async () => { + const rig = await oldAndLiveTurns() + const { assembler, bind, drained } = openUnboundProviderTimelineAssembler(rig.journal) + // Planned before bind, so planning expects `old` to be open; it is a replay of a settled turn. + assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'Hel' }) + await bind() + assembler.flush() + await drained() + expect((await rig.row(providerItemId('item', 'm')))?.turnScope).toEqual({ + kind: 'turn', + turnItemId: providerTurnItemId('live') + }) + + assembler.apply({ type: 'turn.end', turn: 'live', at: 4_000, state: 'completed' }) + await drained() + expect(await rig.turn('live')).toMatchObject({ state: 'completed' }) + assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'lo' }) + assembler.flush() + await drained() + expect(messageText((await rig.row(providerItemId('item', 'm')))?.body)).toBe('Hel') + }) + + it('writes nothing more once another writer of the journal settled its turn', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm' }, + channel: 'assistant', + text: 'Hel' + }) + const running = await rig.turn('t1') + const identity = parseAgentJournalItemKey(providerTurnItemId('t1')) + if (!running || !identity) { + throw new Error('the turn row is written') + } + // A person's Stop, settled by the host on the journal, with no word to the assembler. + await rig.journal.appendItem( + identity, + agentJournalTurnBody({ ...running, state: 'interrupted', completedAt: 2_000 }), + { fence: 1, turnScope: { kind: 'thread' } } + ) + rig.assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'lo' }) + rig.assembler.flush() + expect(messageText((await rig.row(providerItemId('item', 'm')))?.body)).toBe('Hel') + + // The open turn is over for the assembler too: new work is no longer that turn's. + expect(rig.assembler.openTurnId).toBeNull() + }) + + it('still takes the provider final full snapshot of a message whose turn settled', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm' }, + channel: 'assistant', + text: 'Hel' + }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ + type: 'item.close', + item: 'm', + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Hello.' }] } + }) + expect(messageText((await rig.row(providerItemId('item', 'm')))?.body)).toBe('Hello.') + }) +}) + +describe('a message boundary is the journal decision', () => { + it('keeps an anonymous message whole across a queued open the journal held as a replay', async () => { + const rig = await oldAndLiveTurns() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) + assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Hel' + }) + assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'lo' + }) + assembler.apply({ type: 'text.close', item: { stream: 'reply' } }) + await bind() + expect(await messages(rig)).toEqual(['Hello']) + }) + + it('still splits an anonymous message at a turn the journal really opened', async () => { + const rig = await oldAndLiveTurns() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'One' + }) + assembler.apply({ type: 'turn.open', turn: 'next', at: 4_000 }) + assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Two' + }) + assembler.apply({ type: 'text.close', item: { stream: 'reply' } }) + await bind() + expect(await messages(rig)).toEqual(['One', 'Two']) + }) + + it('resumes a named message after a queued open the journal held as a replay', async () => { + const rig = await oldAndLiveTurns() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'Hel' }) + assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'lo' }) + assembler.apply({ type: 'text.close', item: { id: 'm' } }) + await bind() + expect(await messages(rig)).toEqual(['Hello']) + expect((await rig.row(providerItemId('item', 'm')))?.turnScope).toEqual({ + kind: 'turn', + turnItemId: providerTurnItemId('live') + }) + }) +}) + +describe('the open budget counts every provider string a stream keeps', () => { + it('refuses a stream whose thread alone is past the budget', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + const thread = 't'.repeat(1024 * 1024 + 1) + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm' }, + channel: 'assistant', + text: 'x', + join: { thread } + }).admission + ).toEqual({ accepted: false, reason: 'failed' }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts index 0b1d20746c58..dbcacbe9e1dc 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -14,6 +14,11 @@ import type { AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { backgroundTaskFallbackText } from '../../../shared/native-chat-background-task-row' +import { + isBackgroundTaskBlock, + type NativeChatBackgroundTaskBlock +} from '../../../shared/native-chat-types' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { @@ -75,6 +80,35 @@ export function runningTool(name: string): AgentJournalToolCallItem { return { kind: 'tool-call', name, input: { name }, state: 'running' } } +/** A background task's row as every lane writes it: its plain-text twin, then its block. */ +export function backgroundTask( + taskId: string, + state: NativeChatBackgroundTaskBlock['state'] +): AgentJournalMessageItem { + const block: NativeChatBackgroundTaskBlock = { + type: 'background-task', + taskId, + kind: 'command', + label: taskId, + state + } + return { + kind: 'message', + role: 'system', + blocks: [{ type: 'text', text: backgroundTaskFallbackText(block) }, block] + } +} + +/** The run state of the background task in the row of provider item `item`. */ +export async function backgroundTaskState( + rig: ProviderTimelineRig, + item: string +): Promise { + const body = (await rig.row(providerItemId('item', item)))?.body + const block = body?.kind === 'message' ? body.blocks.find(isBackgroundTaskBlock) : undefined + return block?.state +} + export function assistantText(text: string): AgentJournalMessageItem { return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } } @@ -104,6 +138,47 @@ export async function closeProviderTimelineRigs(): Promise { await journals.closeAll() } +/** An assembler whose sink binds to the rig's journal only when `bind` is called, as a lane that + * starts before its journal opens: it plans without the journal's view. */ +export type UnboundProviderTimelineAssembler = { + assembler: ProviderTimelineAssembler + bind(): Promise + drained(): Promise +} + +export function openUnboundProviderTimelineAssembler( + journal: AgentSessionJournal, + overrides: Partial = {} +): UnboundProviderTimelineAssembler { + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) + const sink = providerTimelineSink(deferred.sink) + if (!sink) { + throw new Error('the deferred sink offers transitions') + } + const assembler = createProviderTimelineAssembler({ + sink, + sessionId: SESSION, + agent: AGENT, + generation: 'gen-unbound', + namespace: NAMESPACE, + // The window never fires on its own; `flush` writes what it holds. + schedule: () => () => {}, + ...overrides + }) + cleanups.push(async () => { + assembler.dispose() + deferred.close() + }) + return { + assembler, + bind: async () => { + deferred.bind({ journal, fence: 1, publish: () => {} }) + await deferred.drained() + }, + drained: () => deferred.drained() + } +} + export type ProviderTimelineRig = { journal: AgentSessionJournal assembler: ProviderTimelineAssembler diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts index 4fcb4459190a..af1918c24b1c 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -180,9 +180,11 @@ export function createProviderTimelineAssembler( return { admission: PROVIDER_TIMELINE_OVER_BUDGET } } const plan = new ProviderTimelinePlan() - planProviderTimelineBarrier(host, plan, event, decision) const entry: ProviderTimelineInFlight = { executed: false, commit: decision.commit } let executed: ProviderTimelineDecision | null = null + // A message boundary is the event's only when its own decision, at execution, wrote. + const landed = () => executed !== null && !executed.dropped && executed.write !== null + planProviderTimelineBarrier(host, plan, event, decision, landed) // The event's decision on the ledger, taken once, by its first slot to run. const decide = (at: StructuredAgentSessionTransitionJournal) => { if (!executed) { @@ -210,8 +212,8 @@ export function createProviderTimelineAssembler( const apply = (event: ProviderTimelineEvent): ProviderTimelineApplyResult => { const journal = deps.sink.journalItems() - if (journal && !forecast.hydrated) { - forecast.hydrate(journal) + if (journal) { + forecast.reconcile(journal) } switch (event.type) { case 'text.delta': diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts new file mode 100644 index 000000000000..3b4d004160fd --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts @@ -0,0 +1,68 @@ +// Work that outlives its turn is a background-task row: a message carrying a `background-task` +// block with its own run state, the row every lane writes for it. It is not open work a turn's +// end settles, so it outlives its turn by what the row is, whichever run of the assembler ends +// that turn; only its own updates, or the session's end, change its state. + +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { + backgroundTaskFallbackText, + canReplaceBackgroundTaskState, + isSettledBackgroundTaskState +} from '../../../shared/native-chat-background-task-row' +import { isBackgroundTaskBlock } from '../../../shared/native-chat-types' + +/** The row once the host lost the session that ran its tasks: every task still in flight is + * `unverifiable` (nothing says it exited), with its plain-text twin restated to match. Null when + * no task in it is in flight. */ +export function lostProviderTimelineBackgroundTasks( + body: AgentJournalItemBody +): AgentJournalItemBody | null { + if (body.kind !== 'message') { + return null + } + const restated = new Map() + const blocks = body.blocks.map((block) => { + if (!isBackgroundTaskBlock(block) || isSettledBackgroundTaskState(block.state)) { + return block + } + const lost = { ...block, state: 'unverifiable' as const } + restated.set(backgroundTaskFallbackText(block), backgroundTaskFallbackText(lost)) + return lost + }) + if (restated.size === 0) { + return null + } + return { + ...body, + blocks: blocks.map((block) => + block.type === 'text' && restated.has(block.text) + ? { ...block, text: restated.get(block.text) ?? block.text } + : block + ) + } +} + +/** Whether `next` would move a task `held` already settled back to a state it may not take. */ +export function relightsProviderTimelineBackgroundTask( + held: AgentJournalItemBody | null, + next: AgentJournalItemBody +): boolean { + if (held?.kind !== 'message' || next.kind !== 'message') { + return false + } + const states = new Map() + for (const block of held.blocks) { + if (isBackgroundTaskBlock(block)) { + states.set(block.taskId, block.state) + } + } + return next.blocks.some((block) => { + const current = isBackgroundTaskBlock(block) ? states.get(block.taskId) : undefined + return ( + isBackgroundTaskBlock(block) && + current !== undefined && + current !== block.state && + !canReplaceBackgroundTaskState(current, block.state) + ) + }) +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts index b451973b6710..16cdee07da9e 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts @@ -21,14 +21,13 @@ export type ProviderTimelineContext = { settlementId(what: string): string } -/** The ledger, with its open turn taken from the journal before its first decision runs. */ +/** The ledger, with its open turn taken from the journal before its first decision runs, and + * ended when another writer of the journal ended it. */ export function providerTimelineLedger( context: ProviderTimelineContext, journal: StructuredAgentSessionTransitionJournal ): ProviderTimelineState { - if (!context.ledger.hydrated) { - context.ledger.hydrate(journal) - } + context.ledger.reconcile(journal) return context.ledger } @@ -55,13 +54,20 @@ export function providerTimelinePlacement( } } -/** What an entry the assembler holds open costs: every provider string it keeps, and its body. */ -export function providerTimelineEntryBytes( - key: string, - body?: unknown, - producer?: AgentJournalProducerLinkage -): number { +/** What an entry the assembler holds open costs: its body, its producer, and every provider + * string it keeps (its key and join), each twice: as given, and inside the identities spelled + * from it. */ +export function providerTimelineEntryBytes(input: { + key: string + join: ProviderTimelineJoin | undefined + body?: unknown + producer: AgentJournalProducerLinkage | undefined +}): number { const measure = (value: unknown) => value === undefined ? 0 : Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8') - return Buffer.byteLength(key, 'utf8') + measure(body) + measure(producer) + 64 + const kept = [input.key, input.join?.thread ?? '', input.join?.turn ?? ''].reduce( + (total, part) => total + Buffer.byteLength(part, 'utf8'), + 0 + ) + return 2 * kept + measure(input.body) + measure(input.producer) + 64 } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts index ae75f819f524..ee13faf44a36 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -97,6 +97,5 @@ export function settlementOf( if (!input.execute || !input.journal) { return undefined } - const scope = { turnItemId: turn.itemId, outlivesTurn: input.state.outliving(turn.itemId) } - return providerTimelineSettlement(input.journal, scope, [turn], end) + return providerTimelineSettlement(input.journal, { turnItemId: turn.itemId }, [turn], end) } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts index 1a1ca6ad1746..b3f01ff9f7e4 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -87,14 +87,11 @@ export type ProviderTimelineEvent = /** The provider's own measured duration. */ durationMs?: number } - /** Work began. `outlivesTurn` keeps it open past its turn's end (a backgrounded task). */ - | ({ - type: 'item.open' - item: string - body: ProviderTimelineItemBody - outlivesTurn?: true - } & Produced & - Joined) + /** Work began. Work that outlives its turn (a backgrounded task) is a background-task row — a + * message carrying a `background-task` block with its own run state — beside the tool call that + * started it, which closes as usual: no turn's end settles that row; its own updates do, and the + * session's end leaves one still in flight `unverifiable`. */ + | ({ type: 'item.open'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) /** The item's whole current body. Content may be replaced; a settled tool never runs again. */ | ({ type: 'item.update'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) /** The item's whole terminal body; it replaces any text streamed into the same item. */ diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts index 90200b66bae7..d5eb0ffb62ca 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts @@ -3,6 +3,7 @@ import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' import { unhandledProviderFrameJournalItem } from '../agent-session-wire/unhandled-provider-frame' +import { relightsProviderTimelineBackgroundTask } from './provider-timeline-background-tasks' import { providerTimelineEntryBytes, providerTimelinePlacement } from './provider-timeline-context' import { pendingPrompt, @@ -43,23 +44,24 @@ export function decideItem( if (change === 'close' && (closed || settledTool(held))) { return { dropped: 'item-settled' } } - const known = state.obligations.get(ref) - const outlivesTurn = - (event.type === 'item.open' && event.outlivesTurn === true) || known?.outlivesTurn === true - const obligation = change !== 'close' && (requiresTerminalSettlement(event.body) || outlivesTurn) + if (change !== 'open' && relightsProviderTimelineBackgroundTask(held, event.body)) { + return { dropped: 'item-settled' } + } + const obligation = change !== 'close' && requiresTerminalSettlement(event.body) + const placement = providerTimelinePlacement(context, state, event.join) const row = found ?? (input.execute && journal - ? context.joins.place( - join, - providerTimelineItemClass(event.body), - providerTimelinePlacement(context, state, event.join), - journal - ) + ? context.joins.place(join, providerTimelineItemClass(event.body), placement, journal) : null) - const scope = row?.scope ?? providerTimelinePlacement(context, state, event.join).scope + const scope = row?.scope ?? placement.scope const turnItemId = turnOf(scope) - const bytes = providerTimelineEntryBytes(event.item, event.body, event.producer) + const bytes = providerTimelineEntryBytes({ + key: event.item, + join: event.join, + body: event.body, + producer: event.producer + }) return { ...(obligation ? { hold: { key: ref, bytes } } : {}), write: row && { @@ -74,7 +76,7 @@ export function decideItem( ...(change === 'close' ? { closes: ref } : {}), commit: (next) => { if (obligation) { - next.obligations.set(ref, { itemId: row?.itemId ?? null, turnItemId, outlivesTurn, bytes }) + next.obligations.set(ref, { itemId: row?.itemId ?? null, turnItemId, bytes }) } else { next.obligations.delete(ref) } @@ -107,20 +109,31 @@ export function decideRequest( if (pendingPrompt(held) || (state.obligations.has(ref) && held === null)) { return { dropped: 'request-duplicate' } } - const settledTurn = current ? turnOf(current.scope) : null + // The turn this open lands in decides whether it repeats its predecessor: the same key again + // in a turn that is over is that turn's replay; in another, live turn it is a new request. + const placement = providerTimelinePlacement(context, state, event.join) + const target = turnOf(placement.scope) + const previous = current ? turnOf(current.scope) : null if ( held && - settledTurn !== null && - state.status({ itemId: settledTurn }, journal) === 'settled' + previous !== null && + state.status({ itemId: previous }, journal) === 'settled' && + (target === null || + target === previous || + state.status({ itemId: target }, journal) === 'settled') ) { return { dropped: 'request-replayed' } } - const placement = providerTimelinePlacement(context, state, event.join) const row = input.execute && journal ? context.joins.nextRequest(key, event.body.kind, placement, journal) : null - const bytes = providerTimelineEntryBytes(event.request, event.body, event.producer) + const bytes = providerTimelineEntryBytes({ + key: event.request, + join: event.join, + body: event.body, + producer: event.producer + }) return { hold: { key: ref, bytes }, // A row already there is a client's answer or a replay; neither is overwritten. @@ -136,7 +149,6 @@ export function decideRequest( next.obligations.set(ref, { itemId: row?.itemId ?? null, turnItemId: turnOf(row?.scope ?? placement.scope), - outlivesTurn: false, bytes }) } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts index 390160e99e1a..dd4e92166551 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -20,6 +20,7 @@ import { import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { lostProviderTimelineBackgroundTasks } from './provider-timeline-background-tasks' import { agentJournalTurnRowReservedBytes, resolveAgentJournalTurnRowWrite @@ -74,9 +75,7 @@ export function endedProviderTimelineTurn( } /** Which open rows a settlement covers: one turn's, or every row when the session ends. */ -export type ProviderTimelineSettlementScope = - | { turnItemId: string; outlivesTurn: ReadonlySet } - | 'session' +export type ProviderTimelineSettlementScope = { turnItemId: string } | 'session' /** The settled rows of `scope`, then each listed turn's end while the journal holds it running. */ export function providerTimelineSettlement( @@ -90,10 +89,12 @@ export function providerTimelineSettlement( const turnScope = attribution.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE const covered = scope === 'session' || - (turnScope.kind === 'turn' && - turnScope.turnItemId === scope.turnItemId && - !scope.outlivesTurn.has(itemId)) - const settled = covered ? settledProviderTimelineBody(body) : null + (turnScope.kind === 'turn' && turnScope.turnItemId === scope.turnItemId) + // Background tasks outlive turns; only the session's end leaves them past seeing. + const settled = !covered + ? null + : (settledProviderTimelineBody(body) ?? + (scope === 'session' ? lostProviderTimelineBackgroundTasks(body) : null)) const identity = settled ? parseAgentJournalItemKey(itemId) : null if (settled && identity) { // No linkage: a revision keeps the row's own producer. diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts index d3daf3ef7056..4c1a6bde83c3 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -24,21 +24,31 @@ const MAX_CLOSED_ITEMS = 512 /** Turns remembered as settled without asking the journal. */ const MAX_SETTLED_TURNS = 256 /** Sends waiting for a turn; a provider that never opens one cannot grow this without bound. */ -export const MAX_PENDING_INPUTS = 64 +const MAX_PENDING_INPUTS = 64 +const MAX_PENDING_INPUT_BYTES = 64 * 1024 + +function bytes(value: string): number { + return Buffer.byteLength(value, 'utf8') +} export type ProviderTimelineOpenTurn = ProviderTimelineTurnRef & { running: AgentJournalTurnLifecycle } -export type ProviderTimelinePendingInput = { clientMessageId: string; requestedAt: number } +export type ProviderTimelinePendingInput = { + clientMessageId: string + requestedAt: number + /** The turn row the provider said the send opens; absent: the next turn to open. */ + turnItemId?: string +} /** Work still waiting on a row that settles it: a running tool, a backgrounded task, a pending * request. Keyed by the item's join reference (a request's by its key, whatever its incarnation). */ export type ProviderTimelineObligation = { /** The row, once a resolver placed it. */ itemId: string | null + /** The turn whose end settles it; null for a row in no turn. */ turnItemId: string | null - outlivesTurn: boolean bytes: number } @@ -50,8 +60,16 @@ export class ProviderTimelineState { latest: ProviderTimelineTurnRef | null = null inputs: ProviderTimelinePendingInput[] = [] /** Items this run closed, with the turn they closed in. */ - closed = new BoundedMap({ maxEntries: MAX_CLOSED_ITEMS }) - settled = new BoundedMap({ maxEntries: MAX_SETTLED_TURNS }) + closed = new BoundedMap({ + maxEntries: MAX_CLOSED_ITEMS, + maxBytes: MAX_CLOSED_ITEMS * 1024, + sizeOf: (turnItemId, ref) => bytes(ref) + bytes(turnItemId ?? '') + 16 + }) + settled = new BoundedMap({ + maxEntries: MAX_SETTLED_TURNS, + maxBytes: MAX_SETTLED_TURNS * 1024, + sizeOf: (_settled, turnItemId) => bytes(turnItemId) + 8 + }) obligations = new Map() /** Whether the open turn was taken from the journal yet (ledger only). */ hydrated = false @@ -79,29 +97,55 @@ export class ProviderTimelineState { return copy } + /** Settled once anything settled it: this run, or any writer of the journal (a person's Stop). + * Memory says only what the journal cannot yet: a turn opened by a write still queued. */ status( turn: { itemId: string }, journal: StructuredAgentSessionTransitionJournal | null ): ProviderTimelineTurnStatus { - if (this.open?.itemId === turn.itemId) { - return 'running' - } if (this.settled.has(turn.itemId)) { return 'settled' } const row = readAgentJournalTurn(journal?.itemBody(turn.itemId) ?? undefined) - return row === null ? 'absent' : row.state === 'running' ? 'running' : 'settled' + if (row) { + return row.state === 'running' ? 'running' : 'settled' + } + return this.open?.itemId === turn.itemId ? 'running' : 'absent' } - /** The obligations a turn's end leaves open: the ones that outlive it. */ - outliving(turnItemId: string): Set { - const outliving = new Set() - for (const obligation of this.obligations.values()) { - if (obligation.outlivesTurn && obligation.itemId && obligation.turnItemId === turnItemId) { - outliving.add(obligation.itemId) + /** Ends the open turn when the journal holds it settled: another writer ended it. */ + reconcile(journal: StructuredAgentSessionTransitionJournal): void { + if (!this.hydrated) { + this.hydrate(journal) + } + const row = this.open && readAgentJournalTurn(journal.itemBody(this.open.itemId) ?? undefined) + if (this.open && row && row.state !== 'running') { + this.endTurn(this.open) + } + } + + /** A send waiting for its turn; past the bounds the oldest is forgotten (its turn opens as the + * provider's own). */ + wait(pending: ProviderTimelinePendingInput): void { + this.inputs.push(pending) + const size = (input: ProviderTimelinePendingInput) => + bytes(input.clientMessageId) + bytes(input.turnItemId ?? '') + 16 + let held = this.inputs.reduce((total, input) => total + size(input), 0) + while (this.inputs.length > MAX_PENDING_INPUTS || held > MAX_PENDING_INPUT_BYTES) { + const forgotten = this.inputs.shift() + if (!forgotten) { + return } + held -= size(forgotten) } - return outliving + } + + /** The send that opens `turnItemId`: the one that named it, else the oldest that named none. */ + opener(turnItemId: string): ProviderTimelinePendingInput | undefined { + return ( + this.inputs.find((input) => input.turnItemId === turnItemId) ?? + this.inputs.find((input) => input.turnItemId === undefined) + ) } /** The turn is over, and so is everything its settlement covered. */ @@ -112,7 +156,7 @@ export class ProviderTimelineState { this.latest = turn this.settled.set(turn.itemId, true) for (const [key, obligation] of this.obligations) { - if (obligation.turnItemId === turn.itemId && !obligation.outlivesTurn) { + if (obligation.turnItemId === turn.itemId) { this.obligations.delete(key) } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts new file mode 100644 index 000000000000..7c49dc89e790 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts @@ -0,0 +1,168 @@ +// The ledger of which row each text stream writes, changed only at its writes' turn in the queue. +// +// A stream's row is decided by its first write, like every other row: a provider-named message +// the journal already holds is one this run resumes (its turn still running: the stream adopts the +// row's text as its prefix) or a replay (its turn settled, or this run closed it: nothing is +// written). An anonymous stream continues the row of the one before it when the event that ended +// that one turned out a replay. Every later write checks again that the row is not over, against +// the journal, so a turn another writer ended stops the stream there too. + +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { + providerTimelineLedger, + providerTimelinePlacement, + type ProviderTimelineContext +} from './provider-timeline-context' +import { turnOf } from './provider-timeline-decision' +import type { ProviderTimelineRow } from './provider-timeline-joins' +import type { ProviderTimelineState } from './provider-timeline-state' +import type { ProviderTimelineStream } from './provider-timeline-text-streams' + +/** The row a stream writes, and the text it held before this run streamed into it. */ +export type ProviderTimelineStreamRow = { row: ProviderTimelineRow; prefix: string } + +type Owned = ProviderTimelineStreamRow | 'replayed' + +function providerTimelineMessageText(body: AgentJournalItemBody | null): string { + return body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' +} + +export class ProviderTimelineStreamRows { + private readonly owned = new Map() + + constructor( + private readonly context: ProviderTimelineContext, + /** Drops a stream planning still holds open, so its next delta starts another. */ + private readonly forget: (stream: ProviderTimelineStream) => void + ) {} + + /** At a write: the row it lands on, or null when it writes nothing. */ + resolve( + stream: ProviderTimelineStream, + journal: StructuredAgentSessionTransitionJournal + ): ProviderTimelineStreamRow | null { + const ledger = providerTimelineLedger(this.context, journal) + let owned = this.owned.get(stream.id) + if (!owned) { + owned = this.own(stream, ledger, journal) + this.owned.set(stream.id, owned) + } + if (owned === 'replayed' || ledger.ended) { + return null + } + // Every write, not only the first: whoever ended the row's turn since, this run or another + // writer of the journal, the stream never writes into it again. + if (this.over(stream, owned.row, ledger, journal)) { + this.stop(stream) + return null + } + return owned + } + + /** The event that released `stream` ran: `held` when it really ended the stream's message. */ + release(stream: ProviderTimelineStream, held: boolean): void { + stream.boundary = held ? 'held' : 'void' + // A named stream's next delta finds its row again through the journal; an anonymous one is + // kept for the stream that follows it. + if (held || stream.named) { + this.owned.delete(stream.id) + } + } + + /** Stops every stream whose row is over now. */ + retire( + streams: readonly ProviderTimelineStream[], + journal: StructuredAgentSessionTransitionJournal + ): void { + const ledger = providerTimelineLedger(this.context, journal) + for (const stream of streams) { + const owned = this.owned.get(stream.id) + if (owned && owned !== 'replayed' && this.over(stream, owned.row, ledger, journal)) { + this.stop(stream) + } + } + } + + /** The turn the stream's row is in, once a write placed it; else the one planning expects. */ + turn(stream: ProviderTimelineStream): string | null { + const owned = this.owned.get(stream.id) + return owned && owned !== 'replayed' ? turnOf(owned.row.scope) : stream.turnItemId + } + + clear(): void { + this.owned.clear() + } + + private own( + stream: ProviderTimelineStream, + ledger: ProviderTimelineState, + journal: StructuredAgentSessionTransitionJournal + ): Owned { + const { joins } = this.context + if (stream.named && ledger.closed.has(stream.key)) { + return 'replayed' + } + const carried = this.carried(stream, journal) + if (carried) { + return carried + } + const found = stream.named ? joins.find(stream.join, journal) : null + if (found) { + const turnItemId = turnOf(found.scope) + if (turnItemId !== null && ledger.status({ itemId: turnItemId }, journal) === 'settled') { + return 'replayed' + } + return { row: found, prefix: providerTimelineMessageText(journal.itemBody(found.itemId)) } + } + const placement = providerTimelinePlacement(this.context, ledger, { + ...(stream.join.thread === null ? {} : { thread: stream.join.thread }), + ...(stream.turn === undefined ? {} : { turn: stream.turn }) + }) + const itemClass = stream.channel === 'assistant' ? 'message' : 'reasoning' + return { row: joins.place(stream.join, itemClass, placement, journal), prefix: '' } + } + + /** The row of the message this one continues: an earlier anonymous stream whose release the + * journal did not take (its event was a replay). */ + private carried( + stream: ProviderTimelineStream, + journal: StructuredAgentSessionTransitionJournal + ): Owned | null { + let previous = stream.follows + delete stream.follows + for (; previous?.boundary === 'void'; previous = previous.follows) { + const owned = this.owned.get(previous.id) + if (owned) { + this.owned.delete(previous.id) + return owned === 'replayed' + ? owned + : { + row: owned.row, + prefix: providerTimelineMessageText(journal.itemBody(owned.row.itemId)) + } + } + } + return null + } + + /** Whether the row is past streaming into: its item closed, or its turn settled. */ + private over( + stream: ProviderTimelineStream, + row: ProviderTimelineRow, + ledger: ProviderTimelineState, + journal: StructuredAgentSessionTransitionJournal + ): boolean { + const turn = turnOf(row.scope) + return ( + (stream.named && ledger.closed.has(stream.key)) || + (turn !== null && ledger.status({ itemId: turn }, journal) === 'settled') + ) + } + + /** The stream writes nothing more; a later delta under its key starts another. */ + private stop(stream: ProviderTimelineStream): void { + this.owned.set(stream.id, 'replayed') + this.forget(stream) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts index 27c663b4239b..3d18328b9fa2 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts @@ -6,11 +6,7 @@ // message. Deltas accumulate in the shared coalescer, and a row is a snapshot of the text so far. // Text owed to the journal is written inside the next event's transition — every other event is // an ordering barrier — or by the coalescer's window, and is marked written once the sink takes it. -// -// Which row a stream writes is decided when its first write runs, like every other row: a -// provider-named message the journal already holds is a message this run is resuming (its turn -// still running: the stream adopts the row's text as its prefix) or a replay (its turn settled, or -// this run closed it: nothing is written). +// Which row each write lands on is the journal's call, at the write (`provider-timeline-stream-rows.ts`). import type { AgentJournalItemBody, @@ -32,7 +28,6 @@ import type { import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { providerTimelineEntryBytes, - providerTimelineLedger, providerTimelinePlacement, type ProviderTimelineContext } from './provider-timeline-context' @@ -44,6 +39,7 @@ import type { import { providerTimelineKeyPart } from './provider-timeline-identity' import type { ProviderTimelineItemJoin, ProviderTimelineRow } from './provider-timeline-joins' import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' +import { ProviderTimelineStreamRows } from './provider-timeline-stream-rows' import type { ProviderTimelineState } from './provider-timeline-state' /** One open text stream, as planning knows it. */ @@ -63,26 +59,25 @@ export type ProviderTimelineStream = { /** Whether any text was written; a whitespace-only stream completes nothing. */ written: boolean bytes: number + /** The anonymous stream an event released just before this one began: this one continues its + * row unless that event's boundary landed. */ + follows?: ProviderTimelineStream + /** Execution: whether the event that released this stream ended its message. */ + boundary?: 'held' | 'void' } -/** The row a stream writes, and the text it held before this run streamed into it. */ -type Owned = { row: ProviderTimelineRow; prefix: string } | 'replayed' - type Resolved = { identity: ProviderTimelineRow['identity'] body: AgentJournalItemBody options: StructuredAgentSessionItemAppendOptions } -export function providerTimelineMessageText(body: AgentJournalItemBody | null): string { - return body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' -} - export class ProviderTimelineTextStreams { private readonly streams = new Map() private readonly byId = new Map() - /** Ledger: the row each stream writes, decided by its first write. */ - private readonly owned = new Map() + private readonly rows: ProviderTimelineStreamRows + /** Per anonymous slot, the stream a boundary not yet decided released last. */ + private readonly released = new Map() private readonly coalescer private serial = 0 @@ -95,6 +90,7 @@ export class ProviderTimelineTextStreams { schedule?: AgentSessionDeltaCoalescerDeps['schedule'] } ) { + this.rows = new ProviderTimelineStreamRows(deps.context, (stream) => this.forget(stream)) this.coalescer = createAgentSessionDeltaCoalescer({ ...(deps.coalesceMs === undefined ? {} : { windowMs: deps.coalesceMs }), ...(deps.schedule ? { schedule: deps.schedule } : {}), @@ -137,9 +133,13 @@ export class ProviderTimelineTextStreams { const join = 'id' in input.item ? this.itemJoin(input.item, input.join) : this.anonymousJoin(input.join) const placement = providerTimelinePlacement(this.deps.context, input.state, input.join) + const key = this.key(input.item, input.join, input.state) + const follows = named ? undefined : this.released.get(key) + this.released.delete(key) return { id: `${this.deps.generation}:s${this.serial}`, - key: this.key(input.item, input.join, input.state), + key, + ...(follows ? { follows } : {}), join, turn: input.join?.turn, turnItemId: placement.scope.kind === 'turn' ? placement.scope.turnItemId : null, @@ -147,11 +147,11 @@ export class ProviderTimelineTextStreams { channel: input.channel, producer: input.producer, written: false, - bytes: providerTimelineEntryBytes( - 'id' in input.item ? input.item.id : input.item.stream, - undefined, - input.producer - ) + bytes: providerTimelineEntryBytes({ + key: 'id' in input.item ? input.item.id : input.item.stream, + join: input.join, + producer: input.producer + }) } } @@ -185,17 +185,40 @@ export class ProviderTimelineTextStreams { } } - /** Ends the streams `which` selects; their text was flushed by the same event. */ + /** Ends the streams `which` selects; their text was flushed by the same event. Planning starts + * the next message at once; `landed`, asked at execution, says whether the event really ended + * them: when it did not (a replay the journal held), the next anonymous message continues + * this one's row, and a named stream's next delta resumes its row from the journal. */ planRelease( plan: ProviderTimelinePlan, - which: (stream: ProviderTimelineStream) => boolean + which: (stream: ProviderTimelineStream) => boolean, + landed?: () => boolean ): void { const released = [...this.streams.values()].filter(which) if (released.length === 0) { return } - plan.onAdmitted(() => released.forEach((stream) => this.forget(stream))) - plan.atExecution(() => released.forEach((stream) => this.owned.delete(stream.id))) + plan.onAdmitted(() => + released.forEach((stream) => { + this.forget(stream) + if (landed && !stream.named) { + this.released.set(stream.key, stream) + } + }) + ) + plan.atExecution(() => + released.forEach((stream) => this.rows.release(stream, !landed || landed())) + ) + } + + /** At a turn's or the session's end: every stream whose row is over now stops. */ + planRetire(plan: ProviderTimelinePlan): void { + plan.atExecution((journal) => this.rows.retire(this.open, journal)) + } + + /** The turn a stream's row is in, once a write placed it; else the one planning expects. */ + rowTurn(stream: ProviderTimelineStream): string | null { + return this.rows.turn(stream) } /** Ends one stream with the provider's final text, else what streamed; it settles the item. */ @@ -213,18 +236,13 @@ export class ProviderTimelineTextStreams { true ) } - this.planRelease(plan, (each) => each === stream) if (stream.named) { - // The close settles the item for full snapshots too. - plan.atExecution(() => { - const owned = this.owned.get(stream.id) - const row = owned && owned !== 'replayed' ? owned.row : null - this.deps.context.ledger.closed.set( - stream.key, - row?.scope.kind === 'turn' ? row.scope.turnItemId : null - ) - }) + // The close settles the item for full snapshots too, in the turn its row is in. + plan.atExecution(() => + this.deps.context.ledger.closed.set(stream.key, this.rows.turn(stream)) + ) } + this.planRelease(plan, (each) => each === stream) } flush(): void { @@ -233,7 +251,8 @@ export class ProviderTimelineTextStreams { dispose(): void { this.open.forEach((stream) => this.forget(stream)) - this.owned.clear() + this.rows.clear() + this.released.clear() this.coalescer.dispose() } @@ -285,13 +304,8 @@ export class ProviderTimelineTextStreams { providerFinal: boolean, journal: StructuredAgentSessionTransitionJournal ): Resolved | null { - const ledger = providerTimelineLedger(this.deps.context, journal) - let owned = this.owned.get(stream.id) + const owned = this.rows.resolve(stream, journal) if (!owned) { - owned = this.own(stream, ledger, journal) - this.owned.set(stream.id, owned) - } - if (owned === 'replayed' || ledger.ended) { return null } const { row, prefix } = owned @@ -306,31 +320,6 @@ export class ProviderTimelineTextStreams { } } - private own( - stream: ProviderTimelineStream, - ledger: ProviderTimelineState, - journal: StructuredAgentSessionTransitionJournal - ): Owned { - const { joins } = this.deps.context - if (stream.named && ledger.closed.has(stream.key)) { - return 'replayed' - } - const found = stream.named ? joins.find(stream.join, journal) : null - if (found) { - const turnItemId = found.scope.kind === 'turn' ? found.scope.turnItemId : null - if (turnItemId !== null && ledger.status({ itemId: turnItemId }, journal) === 'settled') { - return 'replayed' - } - return { row: found, prefix: providerTimelineMessageText(journal.itemBody(found.itemId)) } - } - const placement = providerTimelinePlacement(this.deps.context, ledger, { - ...(stream.join.thread === null ? {} : { thread: stream.join.thread }), - ...(stream.turn === undefined ? {} : { turn: stream.turn }) - }) - const itemClass = stream.channel === 'assistant' ? 'message' : 'reasoning' - return { row: joins.place(stream.join, itemClass, placement, journal), prefix: '' } - } - private message(stream: ProviderTimelineStream, text: string): AgentJournalItemBody { return { kind: 'message', diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts index 726070438901..35fd7abc14b5 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts @@ -23,7 +23,9 @@ export function planProviderTimelineBarrier( host: ProviderTimelineApplyHost, plan: ProviderTimelinePlan, event: ProviderTimelineDecidedEvent, - decision: ProviderTimelineDecision + decision: ProviderTimelineDecision, + /** Execution: whether the event's own decision wrote, so the boundary it draws is real. */ + landed: () => boolean ): void { const { streams, context } = host const forecast = host.forecast() @@ -31,10 +33,11 @@ export function planProviderTimelineBarrier( const replaced = decision.closes ? streams.get(decision.closes) : undefined streams.planFlush(plan, replaced) if (replaced) { - streams.planRelease(plan, (stream) => stream === replaced) + streams.planRelease(plan, (stream) => stream === replaced, landed) } if (event.type === 'session.ended' || event.type === 'session.reset') { - streams.planRelease(plan, () => true) + streams.planRelease(plan, () => true, landed) + streams.planRetire(plan) return } if (event.type === 'turn.end' || event.type === 'turn.open') { @@ -46,13 +49,20 @@ export function planProviderTimelineBarrier( plan, (stream) => (!stream.named && stream.producer?.agentId === undefined) || - (ending !== undefined && stream.turnItemId === ending) + (ending !== undefined && streams.rowTurn(stream) === ending), + landed ) + // The turn that really ended is the journal's: its streams stop wherever planning placed them. + streams.planRetire(plan) return } if (event.type !== 'input.accepted') { const agentId = 'producer' in event ? event.producer?.agentId : undefined - streams.planRelease(plan, (stream) => !stream.named && stream.producer?.agentId === agentId) + streams.planRelease( + plan, + (stream) => !stream.named && stream.producer?.agentId === agentId, + landed + ) } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts index 2ae960936e0c..a6ee99b15bf1 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts @@ -22,13 +22,14 @@ import { type ProviderTimelineDecisionInput, type ProviderTimelineResolvedWrite } from './provider-timeline-decision' +import { providerTimelinePlacement } from './provider-timeline-context' import type { ProviderTimelineTurnRef } from './provider-timeline-joins' import { providerTimelineSettlement, runningProviderTimelineTurns, type ProviderTimelineTurnEnd } from './provider-timeline-settlement' -import { MAX_PENDING_INPUTS, type ProviderTimelineState } from './provider-timeline-state' +import type { ProviderTimelineState } from './provider-timeline-state' export function decideTurnOpen( input: ProviderTimelineDecisionInput, @@ -47,7 +48,7 @@ export function decideTurnOpen( if (state.status(turn, input.journal) !== 'absent') { return { dropped: 'turn-replayed' } } - const pending = state.inputs[0] + const pending = state.opener(turn.itemId) const running: AgentJournalTurnLifecycle = { turnId: turn.turnId, state: 'running', @@ -80,7 +81,9 @@ export function decideTurnOpen( next.endTurn(next.open) } if (pending) { - next.inputs.shift() + next.inputs = next.inputs.filter( + (input) => input.clientMessageId !== pending.clientMessageId + ) } next.open = { ...turn, running } } @@ -117,32 +120,38 @@ export function decideInput( ): ProviderTimelineDecision { const { state, journal, context } = input const named = event.join?.turn - const turnKey = named === undefined ? (state.open?.address.key ?? null) : providerKey(named) - if (input.execute && journal && turnKey && event.join?.item !== undefined) { + const placement = providerTimelinePlacement(context, state, event.join) + if (input.execute && journal && placement.turn && event.join?.item !== undefined) { context.joins.reserveEcho( { family: 'item', key: providerKey(event.join.item), thread: event.join.thread ?? null }, - { thread: event.join.thread ?? null, turn: turnKey, scope: state.scope }, + placement, journal ) } const open = state.open + const namedTurn = + named === undefined ? null : context.joins.turn(providerKey(named), state.namespace) + const status = namedTurn ? state.status(namedTurn, journal) : null // A late echo of a turn already over names nothing. - if ( - named !== undefined && - context.joins.turn(providerKey(named), state.namespace).itemId !== open?.itemId - ) { + if (status === 'settled' || (status === 'running' && namedTurn?.itemId !== open?.itemId)) { return {} } - const pending = { clientMessageId: event.clientMessageId, requestedAt: event.requestedAt } - if (!open) { + const pending = { + clientMessageId: event.clientMessageId, + requestedAt: event.requestedAt, + ...(namedTurn && status === 'absent' ? { turnItemId: namedTurn.itemId } : {}) + } + // No turn open, or the send names one still to open: it waits for that turn. + if (!open || pending.turnItemId !== undefined) { return { - commit: (next) => { - next.inputs.push(pending) - next.inputs.splice(0, Math.max(0, next.inputs.length - MAX_PENDING_INPUTS)) - } + commit: (next) => next.wait(pending) } } - if (open.running.userItemId !== context.joins.turnOpener(open)) { + // Only a turn still naming its fallback opener takes the send; the journal's row says, once there. + const opener = + readAgentJournalTurn(journal?.itemBody(open.itemId) ?? undefined)?.userItemId ?? + open.running.userItemId + if (opener !== context.joins.turnOpener(open)) { return {} } const userItemId = agentJournalSubmissionKey(event.clientMessageId) @@ -158,8 +167,6 @@ export function decideInput( } } -/** Only the opener fields change; the rest are the row's as the journal holds it. */ - /** Only the opener fields change; the rest are the row's as the journal holds it. */ export function reviseOpener( journal: StructuredAgentSessionTransitionJournal, From a9dbaaa0ee0da42841c8f5a631c78c048d14dfc8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:19:45 -0700 Subject: [PATCH 042/269] Type the unbound assembler helper's drain as the void it reports --- .../provider-timeline-assembler-test-support.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts index dbcacbe9e1dc..c7e66edb98bb 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -175,7 +175,9 @@ export function openUnboundProviderTimelineAssembler( deferred.bind({ journal, fence: 1, publish: () => {} }) await deferred.drained() }, - drained: () => deferred.drained() + drained: async () => { + await deferred.drained() + } } } From 50c93fb9f3c973e1cbf50e5af62c642c9654fca9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:28:22 -0700 Subject: [PATCH 043/269] Bound the rows kept for a continued anonymous message and the stopped streams A row kept for the anonymous stream that may continue it, and the marker that a stopped stream's queued writes write nothing, lived in the live-stream map and were never removed when no stream followed. They now live in their own bounded maps, so the live map holds open streams only. --- .../provider-timeline-state.ts | 4 +- .../provider-timeline-stream-rows.ts | 49 +++++++++++++------ .../provider-timeline-text-streams.ts | 3 +- 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts index 4c1a6bde83c3..a74fd943bcdd 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -42,8 +42,8 @@ export type ProviderTimelinePendingInput = { turnItemId?: string } -/** Work still waiting on a row that settles it: a running tool, a backgrounded task, a pending - * request. Keyed by the item's join reference (a request's by its key, whatever its incarnation). */ +/** Work still waiting on a row that settles it: a running tool or a pending request. Keyed by + * the item's join reference (a request's by its key, whatever its incarnation). */ export type ProviderTimelineObligation = { /** The row, once a resolver placed it. */ itemId: string | null diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts index 7c49dc89e790..b362062b25ca 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts @@ -8,6 +8,7 @@ // the journal, so a turn another writer ended stops the stream there too. import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { BoundedMap } from '../../../shared/bounded-map' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { providerTimelineLedger, @@ -24,12 +25,22 @@ export type ProviderTimelineStreamRow = { row: ProviderTimelineRow; prefix: stri type Owned = ProviderTimelineStreamRow | 'replayed' +/** Rows kept for the anonymous stream that may continue them, and streams stopped mid-flight + * whose queued writes must still write nothing. */ +const MAX_CARRIED_ROWS = 64 +const MAX_STOPPED_STREAMS = 512 + function providerTimelineMessageText(body: AgentJournalItemBody | null): string { return body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' } export class ProviderTimelineStreamRows { + /** The streams planning still holds open. */ private readonly owned = new Map() + private readonly carried = new BoundedMap({ + maxEntries: MAX_CARRIED_ROWS + }) + private readonly stopped = new BoundedMap({ maxEntries: MAX_STOPPED_STREAMS }) constructor( private readonly context: ProviderTimelineContext, @@ -42,6 +53,9 @@ export class ProviderTimelineStreamRows { stream: ProviderTimelineStream, journal: StructuredAgentSessionTransitionJournal ): ProviderTimelineStreamRow | null { + if (this.stopped.has(stream.id)) { + return null + } const ledger = providerTimelineLedger(this.context, journal) let owned = this.owned.get(stream.id) if (!owned) { @@ -63,10 +77,12 @@ export class ProviderTimelineStreamRows { /** The event that released `stream` ran: `held` when it really ended the stream's message. */ release(stream: ProviderTimelineStream, held: boolean): void { stream.boundary = held ? 'held' : 'void' - // A named stream's next delta finds its row again through the journal; an anonymous one is - // kept for the stream that follows it. - if (held || stream.named) { - this.owned.delete(stream.id) + const owned = this.owned.get(stream.id) + this.owned.delete(stream.id) + // A named stream's next delta finds its row again through the journal; an anonymous one's row + // is kept for the stream that follows it. + if (!held && !stream.named && owned && owned !== 'replayed') { + this.carried.set(stream.id, owned) } } @@ -92,6 +108,8 @@ export class ProviderTimelineStreamRows { clear(): void { this.owned.clear() + this.carried.clear() + this.stopped.clear() } private own( @@ -103,7 +121,7 @@ export class ProviderTimelineStreamRows { if (stream.named && ledger.closed.has(stream.key)) { return 'replayed' } - const carried = this.carried(stream, journal) + const carried = this.continued(stream, journal) if (carried) { return carried } @@ -125,22 +143,20 @@ export class ProviderTimelineStreamRows { /** The row of the message this one continues: an earlier anonymous stream whose release the * journal did not take (its event was a replay). */ - private carried( + private continued( stream: ProviderTimelineStream, journal: StructuredAgentSessionTransitionJournal ): Owned | null { let previous = stream.follows delete stream.follows for (; previous?.boundary === 'void'; previous = previous.follows) { - const owned = this.owned.get(previous.id) - if (owned) { - this.owned.delete(previous.id) - return owned === 'replayed' - ? owned - : { - row: owned.row, - prefix: providerTimelineMessageText(journal.itemBody(owned.row.itemId)) - } + const kept = this.carried.get(previous.id) + if (kept) { + this.carried.delete(previous.id) + return { + row: kept.row, + prefix: providerTimelineMessageText(journal.itemBody(kept.row.itemId)) + } } } return null @@ -162,7 +178,8 @@ export class ProviderTimelineStreamRows { /** The stream writes nothing more; a later delta under its key starts another. */ private stop(stream: ProviderTimelineStream): void { - this.owned.set(stream.id, 'replayed') + this.owned.delete(stream.id) + this.stopped.set(stream.id, true) this.forget(stream) } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts index 3d18328b9fa2..1557873426b6 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts @@ -36,6 +36,7 @@ import type { ProviderTimelineTextChannel, ProviderTimelineTextItem } from './provider-timeline-event' +import { BoundedMap } from '../../../shared/bounded-map' import { providerTimelineKeyPart } from './provider-timeline-identity' import type { ProviderTimelineItemJoin, ProviderTimelineRow } from './provider-timeline-joins' import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' @@ -77,7 +78,7 @@ export class ProviderTimelineTextStreams { private readonly byId = new Map() private readonly rows: ProviderTimelineStreamRows /** Per anonymous slot, the stream a boundary not yet decided released last. */ - private readonly released = new Map() + private readonly released = new BoundedMap({ maxEntries: 64 }) private readonly coalescer private serial = 0 From e4a78c8a99f00eea0ba01e39afefbbbee67b1e95 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:33:29 -0700 Subject: [PATCH 044/269] test(native-chat): cover registered agents on the wire, in storage and across versions --- ...agent-session-legacy-record-import.test.ts | 13 +- ...session-provider-handle-transition.test.ts | 8 +- ...t-session-record-conversation-name.test.ts | 14 +- ...runtime-structured-session-restore.test.ts | 6 + ...on-tab-registered-agent-projection.test.ts | 110 ++++++ ...ured-agent-session-adoption-replay.test.ts | 2 + ...-agent-session-startup-tab-restore.test.ts | 10 +- ...tructured-agent-stored-definitions.test.ts | 17 + src/shared/agent-session-account-home.test.ts | 60 ++- .../agent-session-record-stored-form.test.ts | 9 +- .../agent-session-registered-agents.test.ts | 75 ++++ src/shared/agent-session-stored-agent.test.ts | 70 ++++ ...uctured-agent-session-agent-params.test.ts | 57 +++ ...tructured-native-chat-launch-route.test.ts | 40 +- src/shared/workspace-session-schema.test.ts | 40 ++ ...oss-version-registered-agents.unit.test.ts | 371 ++++++++++++++++++ .../structured-agent-session-host-fixture.ts | 4 +- ...ructured-agent-session-surface-manifest.ts | 9 +- 18 files changed, 878 insertions(+), 37 deletions(-) create mode 100644 src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts create mode 100644 src/main/runtime/structured-agent-stored-definitions.test.ts create mode 100644 src/shared/agent-session-registered-agents.test.ts create mode 100644 src/shared/agent-session-stored-agent.test.ts create mode 100644 src/shared/rpc-contract/structured-agent-session-agent-params.test.ts create mode 100644 tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts diff --git a/src/main/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts index 3a9e47fe6c29..6eeeba7eda1d 100644 --- a/src/main/runtime/agent-session-legacy-record-import.test.ts +++ b/src/main/runtime/agent-session-legacy-record-import.test.ts @@ -25,6 +25,7 @@ import { AgentSessionRecordStore } from './agent-session-record-store' import { legacyAgentSessionStorePath } from './agent-session-record-store-file' import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' const NOW = 1_800_000_000_000 const ALPHA = 'session-alpha' @@ -124,7 +125,8 @@ async function install(): Promise<{ const database = await openStructuredAgentSessionJournalDatabase({ logger: log.logger, stateDirectory: root, - hostId: 'local' + hostId: 'local', + agents: CLAUDE_AND_CODEX_STORED_AGENTS }) opened.push(database) // Each report reaches the log as one entry under its scope, its kind as the outcome. @@ -133,7 +135,11 @@ async function install(): Promise<{ .map(({ fields: { scope: _scope, outcome, ...rest } }) => ({ kind: outcome, ...rest })) return { database, - store: AgentSessionRecordStore.open({ journalDatabase: database, hostId: 'local' }), + store: AgentSessionRecordStore.open({ + journalDatabase: database, + hostId: 'local', + agents: CLAUDE_AND_CODEX_STORED_AGENTS + }), reports } } @@ -410,7 +416,8 @@ describe('the tab index from before the table', () => { .run(BETA, JSON.stringify(storedForm(record(BETA)))) await AgentSessionRecordStore.open({ journalDatabase: owed.database, - hostId: 'local' + hostId: 'local', + agents: CLAUDE_AND_CODEX_STORED_AGENTS }).setSessionTabVisibility(BETA, true) owed.database.close() await rm(legacyPath(), { recursive: true }) diff --git a/src/main/runtime/agent-session-provider-handle-transition.test.ts b/src/main/runtime/agent-session-provider-handle-transition.test.ts index fa3eea6ecf6d..d438868a1fc1 100644 --- a/src/main/runtime/agent-session-provider-handle-transition.test.ts +++ b/src/main/runtime/agent-session-provider-handle-transition.test.ts @@ -6,6 +6,7 @@ import { import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record' import { encodeAgentSessionRecord } from '../../shared/agent-session-record-stored-form' +import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' import { recordAgentSessionProviderHandle, reviseAgentSessionProviderResumePoint @@ -75,7 +76,12 @@ describe('reviseAgentSessionProviderResumePoint', () => { handle: { providerData: 'leaf-3' } }) expect(second.lease.provenHandleLinkId).toBe('link-1') - expect(isPersistedAgentSessionRecord(encodeAgentSessionRecord(second))).toBe(true) + expect( + isPersistedAgentSessionRecord( + encodeAgentSessionRecord(second), + CLAUDE_AND_CODEX_STORED_AGENTS + ) + ).toBe(true) }) it('refuses a stale owner, a released lease, and a head minted by another owner', () => { diff --git a/src/main/runtime/agent-session-record-conversation-name.test.ts b/src/main/runtime/agent-session-record-conversation-name.test.ts index f9a3cfb3c91d..058c0b526334 100644 --- a/src/main/runtime/agent-session-record-conversation-name.test.ts +++ b/src/main/runtime/agent-session-record-conversation-name.test.ts @@ -6,6 +6,7 @@ import { import { encodePersistedAgentSessionProviderHandleChain } from '../../shared/agent-session-provider-handle' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' +import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' const NOW = 9_000 @@ -13,10 +14,15 @@ const NOW = 9_000 function storedRecordIsValid( record: Omit & { conversationName?: unknown } ): boolean { - return isPersistedAgentSessionRecord({ - ...record, - providerHandleChain: encodePersistedAgentSessionProviderHandleChain(record.providerHandleChain) - }) + return isPersistedAgentSessionRecord( + { + ...record, + providerHandleChain: encodePersistedAgentSessionProviderHandleChain( + record.providerHandleChain + ) + }, + CLAUDE_AND_CODEX_STORED_AGENTS + ) } describe('agent session record conversationName validation', () => { diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 92c39dda2835..b1ff4fc5ece1 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { OrcaRuntimeService } from './orca-runtime' +import { claudeAndCodexDefinition } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' afterEach(() => setStructuredAgentSessionHost(null)) @@ -76,6 +77,7 @@ describe('structured session cold restoration', () => { setStructuredAgentSessionHost({ reconcileRestartLeases, restoreReadableSessions, + agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [] } as never) @@ -140,6 +142,7 @@ describe('structured session cold restoration', () => { sessionIds: ['session-survives-rollback'] }), restoreReadableSessions, + agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [] } as never) @@ -194,6 +197,7 @@ describe('structured session cold restoration', () => { reconcileRestartLeases: async () => undefined, getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }), restoreReadableSessions, + agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [] } as never) @@ -228,6 +232,7 @@ describe('structured session cold restoration', () => { restoreReadableSessions: async () => undefined, close: closeStructuredSession, setSessionTabVisibility, + agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [ { sessionId: 'agent-session:agent-session:restored-session', @@ -341,6 +346,7 @@ describe('structured session cold restoration', () => { setStructuredAgentSessionHost({ reconcileRestartLeases: async () => undefined, restoreReadableSessions: async () => undefined, + agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [ { sessionId: 'agent-session:agent-session:restored-claude', diff --git a/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts new file mode 100644 index 000000000000..bc446197ca0f --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' +import { + STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE, + assertAgentSessionTabDestructiveMutationSupported, + projectSessionTabAgentStatus +} from './session-tab-agent-status-projection' + +const TODAYS_CLIENT = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +] +const REGISTERED_AGENTS_CLIENT = [ + ...TODAYS_CLIENT, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY +] + +function chatTab(agent: string, isActive: boolean) { + return { + type: 'agent-session' as const, + id: `agent-session:${agent}-session`, + title: `${agent} chat`, + sessionId: `${agent}-session`, + agent, + isActive + } +} + +function snapshot(): RuntimeMobileSessionTabsSnapshot { + return { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-a', + activeTabId: 'agent-session:grok-session', + activeTabType: 'agent-session', + tabGroups: [ + { + id: 'group-a', + activeTabId: 'agent-session:grok-session', + tabOrder: [ + 'agent-session:claude-session', + 'agent-session:codex-session', + 'agent-session:grok-session' + ] + } + ], + tabs: [chatTab('claude', false), chatTab('codex', false), chatTab('grok', true)] + } +} + +describe('a tab of an agent beyond Claude and Codex', () => { + it("is withheld from a paired client that does not render the host's agents", () => { + const projected = projectSessionTabAgentStatus(snapshot(), 'runtime', TODAYS_CLIENT) + expect(projected.tabs.map((tab) => tab.id)).toEqual([ + 'agent-session:claude-session', + 'agent-session:codex-session' + ]) + expect(projected.tabGroups?.[0]?.tabOrder).not.toContain('agent-session:grok-session') + expect(projected.activeTabId).not.toBe('agent-session:grok-session') + }) + + it('reaches a client that advertises the registered-agents capability unchanged', () => { + const payload = snapshot() + expect(projectSessionTabAgentStatus(payload, 'runtime', REGISTERED_AGENTS_CLIENT)).toBe(payload) + }) + + it('stays listed on a phone under the title that names the fix', () => { + const projected = projectSessionTabAgentStatus(snapshot(), 'mobile', TODAYS_CLIENT) + expect(projected.tabs.map((tab) => tab.title)).toEqual([ + 'claude chat', + 'codex chat', + STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE + ]) + }) + + it('cannot be closed by a client that cannot render it', () => { + expect(() => + assertAgentSessionTabDestructiveMutationSupported( + snapshot(), + 'agent-session:grok-session', + 'runtime', + TODAYS_CLIENT + ) + ).toThrow('structured_agent_session_unsupported') + expect(() => + assertAgentSessionTabDestructiveMutationSupported( + snapshot(), + 'agent-session:grok-session', + 'runtime', + REGISTERED_AGENTS_CLIENT + ) + ).not.toThrow() + }) + + it('leaves Claude and Codex tabs as they were for every client', () => { + const payload = { ...snapshot(), tabs: snapshot().tabs.slice(0, 2) } + expect(projectSessionTabAgentStatus(payload, 'runtime', TODAYS_CLIENT)).toBe(payload) + expect( + projectSessionTabAgentStatus(payload, 'runtime', [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]).tabs.map((tab) => tab.id) + ).toEqual(['agent-session:codex-session']) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 8f7b60de517c..99b17ab90485 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -7,6 +7,7 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/ import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { claudeAndCodexDefinition } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import { OrcaRuntimeService } from '../../orca-runtime' import type { RpcRequest, RpcResponse } from '../core' @@ -32,6 +33,7 @@ let host: StructuredAgentSessionHost function adapter(): StructuredAgentSessionAdapter { return { supportsCreate: () => true, + definition: claudeAndCodexDefinition, acquire: vi .fn() .mockImplementation(async ({ fence, spawnToken }) => ({ diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index 70be04a9de72..dcbb53c2bb03 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -39,6 +39,7 @@ import { import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' // `failing` fails every record write; `grants` lets that many more through, then fails. const writes = vi.hoisted(() => ({ failing: false, grants: Infinity, refused: 0 })) @@ -142,7 +143,8 @@ async function seedProfile( const database = await openStructuredAgentSessionJournalDatabase({ logger: createStructuredAgentSessionLogger(), stateDirectory: root, - hostId: 'local' + hostId: 'local', + agents: CLAUDE_AND_CODEX_STORED_AGENTS }) for (const record of options.history ?? records) { const fence = record.lease.runtimeFence @@ -182,14 +184,16 @@ async function seedChatOpenedWhileOwed(record: AgentSessionRecord, tabId: string const database = await openStructuredAgentSessionJournalDatabase({ logger: createStructuredAgentSessionLogger(), stateDirectory: root, - hostId: 'local' + hostId: 'local', + agents: CLAUDE_AND_CODEX_STORED_AGENTS }) database.db .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') .run(record.sessionId, JSON.stringify(storedTestAgentSessionRecord(record))) await AgentSessionRecordStore.open({ journalDatabase: database, - hostId: 'local' + hostId: 'local', + agents: CLAUDE_AND_CODEX_STORED_AGENTS }).setSessionTabVisibility(record.sessionId, true, tabId) database.close() await rm(legacyAgentSessionStorePath(root), { recursive: true }) diff --git a/src/main/runtime/structured-agent-stored-definitions.test.ts b/src/main/runtime/structured-agent-stored-definitions.test.ts new file mode 100644 index 000000000000..a5842fe9c796 --- /dev/null +++ b/src/main/runtime/structured-agent-stored-definitions.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' +import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' +import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' + +describe('what the shipped definitions let a record store', () => { + it('matches the storage every older build wrote', () => { + for (const definition of [CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT]) { + const { agent, handleTransport, accountHomeVariable } = definition + expect({ agent, handleTransport, accountHomeVariable }).toEqual( + CLAUDE_AND_CODEX_STORED_AGENTS.get(agent) + ) + } + expect(CLAUDE_STRUCTURED_AGENT.accountHomeVariable).toBe('CLAUDE_CONFIG_DIR') + expect(CODEX_STRUCTURED_AGENT.accountHomeVariable).toBe('CODEX_HOME') + }) +}) diff --git a/src/shared/agent-session-account-home.test.ts b/src/shared/agent-session-account-home.test.ts index 047f88f19b30..86e6e3bc1147 100644 --- a/src/shared/agent-session-account-home.test.ts +++ b/src/shared/agent-session-account-home.test.ts @@ -1,37 +1,59 @@ import { describe, expect, it } from 'vitest' -import { - agentConfigDirectoryVariable, - agentSessionAccountHome, - isAgentConfigDirectoryVariable -} from './agent-session-account-home' +import { agentSessionAccountHome } from './agent-session-account-home' import { isPersistedAgentSessionRecord } from './agent-session-record' import { agentSessionRecordFixture } from './agent-session-record.test-fixture' import { encodeAgentSessionRecord } from './agent-session-record-stored-form' +import { + agentSessionStoredAgents, + isDeclaredAccountHomeVariable +} from './agent-session-stored-agent' +import { CLAUDE_AND_CODEX_STORED_AGENTS } from './agent-session-stored-agent.test-fixture' describe('agent session account home', () => { it('pins the variables older builds wrote and read', () => { - expect(agentConfigDirectoryVariable('claude')).toBe('CLAUDE_CONFIG_DIR') - expect(agentConfigDirectoryVariable('codex')).toBe('CODEX_HOME') - expect(JSON.stringify(agentSessionAccountHome('codex', '/home/dev/.codex'))).toBe( - '{"variable":"CODEX_HOME","path":"/home/dev/.codex"}' + expect(CLAUDE_AND_CODEX_STORED_AGENTS.get('claude')?.accountHomeVariable).toBe( + 'CLAUDE_CONFIG_DIR' ) + expect(CLAUDE_AND_CODEX_STORED_AGENTS.get('codex')?.accountHomeVariable).toBe('CODEX_HOME') + expect( + JSON.stringify( + agentSessionAccountHome(CLAUDE_AND_CODEX_STORED_AGENTS.get('codex')!, '/home/dev/.codex') + ) + ).toBe('{"variable":"CODEX_HOME","path":"/home/dev/.codex"}') }) - it('admits only a variable some agent declares', () => { - expect(isAgentConfigDirectoryVariable('CLAUDE_CONFIG_DIR')).toBe(true) - expect(isAgentConfigDirectoryVariable('CODEX_HOME')).toBe(true) - expect(isAgentConfigDirectoryVariable('PATH')).toBe(false) - expect(isAgentConfigDirectoryVariable(undefined)).toBe(false) + it('admits only a variable a registered agent declares', () => { + expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, 'CLAUDE_CONFIG_DIR')).toBe( + true + ) + expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, 'CODEX_HOME')).toBe(true) + expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, 'PATH')).toBe(false) + expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, undefined)).toBe(false) + const withGrok = agentSessionStoredAgents([ + ...CLAUDE_AND_CODEX_STORED_AGENTS.values(), + { agent: 'grok', handleTransport: 'acp', accountHomeVariable: 'GROK_HOME' } + ]) + expect(isDeclaredAccountHomeVariable(withGrok, 'GROK_HOME')).toBe(true) + expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, 'GROK_HOME')).toBe(false) }) it('refuses a stored record whose account home names an undeclared variable', () => { const record = encodeAgentSessionRecord(agentSessionRecordFixture()) - expect(isPersistedAgentSessionRecord(record)).toBe(true) + expect(isPersistedAgentSessionRecord(record, CLAUDE_AND_CODEX_STORED_AGENTS)).toBe(true) expect( - isPersistedAgentSessionRecord({ - ...record, - accountHome: { variable: 'LD_PRELOAD', path: '/tmp/x' } - }) + isPersistedAgentSessionRecord( + { ...record, accountHome: { variable: 'LD_PRELOAD', path: '/tmp/x' } }, + CLAUDE_AND_CODEX_STORED_AGENTS + ) ).toBe(false) }) + + it('refuses registering one agent twice', () => { + expect(() => + agentSessionStoredAgents([ + ...CLAUDE_AND_CODEX_STORED_AGENTS.values(), + { agent: 'codex', handleTransport: 'acp', accountHomeVariable: 'CODEX_HOME' } + ]) + ).toThrow('registered twice') + }) }) diff --git a/src/shared/agent-session-record-stored-form.test.ts b/src/shared/agent-session-record-stored-form.test.ts index 3c4381a2f432..1cabb516ca14 100644 --- a/src/shared/agent-session-record-stored-form.test.ts +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -12,6 +12,7 @@ import { encodePersistedAgentSessionProviderHandle } from './agent-session-provider-handle-encoding' import { isPersistedAgentSessionRecord } from './agent-session-record' +import { CLAUDE_AND_CODEX_STORED_AGENTS } from './agent-session-stored-agent.test-fixture' import { decodePersistedAgentSessionRecord, encodeAgentSessionRecord @@ -97,8 +98,8 @@ describe('a record row older builds wrote', () => { ['Codex', STORED_CODEX_ROW] ])('reads a %s row and writes it back byte for byte', (_name, row) => { const stored: unknown = JSON.parse(row) - expect(isPersistedAgentSessionRecord(stored)).toBe(true) - if (!isPersistedAgentSessionRecord(stored)) { + expect(isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)).toBe(true) + if (!isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)) { return } const { record, normalized } = decodePersistedAgentSessionRecord(stored) @@ -108,7 +109,7 @@ describe('a record row older builds wrote', () => { it('hands shared code only the neutral handle', () => { const stored: unknown = JSON.parse(STORED_CLAUDE_ROW) - if (!isPersistedAgentSessionRecord(stored)) { + if (!isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)) { throw new Error('fixture row must be readable') } const { record } = decodePersistedAgentSessionRecord(stored) @@ -247,7 +248,7 @@ describe('what this build writes', () => { nativeId: 'acp-thread', providerData: 'resume-token' }) - expect(isPersistedAgentSessionRecord(stored)).toBe(false) + expect(isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)).toBe(false) }) }) diff --git a/src/shared/agent-session-registered-agents.test.ts b/src/shared/agent-session-registered-agents.test.ts new file mode 100644 index 000000000000..9e9427c3a7c6 --- /dev/null +++ b/src/shared/agent-session-registered-agents.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { decodeAgentSessionAgentsResult } from './agent-session-registered-agents' + +const CODEX = { + agent: 'codex', + capabilities: { + rewind: true, + compact: true, + threadGoal: true, + contextUsage: false, + imagePrompts: true, + steering: 'inject', + approvalEnforcement: 'provider' + } +} + +describe('decodeAgentSessionAgentsResult', () => { + it('reads every well-formed agent', () => { + expect(decodeAgentSessionAgentsResult({ agents: [CODEX] })).toEqual([CODEX]) + }) + + it('drops a row it cannot read and keeps the rest', () => { + expect( + decodeAgentSessionAgentsResult({ + agents: [ + { agent: 'not an id!', capabilities: CODEX.capabilities }, + { agent: 'grok' }, + { agent: 'opencode', capabilities: { ...CODEX.capabilities, rewind: 'yes' } }, + CODEX + ] + }) + ).toEqual([CODEX]) + }) + + it('degrades an arm or flag a newer host adds to the one that claims least', () => { + expect( + decodeAgentSessionAgentsResult({ + agents: [ + { + agent: 'grok', + capabilities: { + steering: 'interrupt', + approvalEnforcement: 'sandbox', + someLaterCapability: true + } + } + ] + }) + ).toEqual([ + { + agent: 'grok', + capabilities: { + rewind: false, + compact: false, + threadGoal: false, + contextUsage: false, + imagePrompts: false, + steering: 'queue', + approvalEnforcement: 'orca' + } + } + ]) + }) + + it('keeps the first row of a repeated agent', () => { + const later = { ...CODEX, capabilities: { ...CODEX.capabilities, rewind: false } } + expect(decodeAgentSessionAgentsResult({ agents: [CODEX, later] })).toEqual([CODEX]) + }) + + it('answers null for a reply that is not an agent list', () => { + expect(decodeAgentSessionAgentsResult(null)).toBeNull() + expect(decodeAgentSessionAgentsResult({})).toBeNull() + expect(decodeAgentSessionAgentsResult({ agents: 'claude' })).toBeNull() + }) +}) diff --git a/src/shared/agent-session-stored-agent.test.ts b/src/shared/agent-session-stored-agent.test.ts new file mode 100644 index 000000000000..b86bc1bd6f1e --- /dev/null +++ b/src/shared/agent-session-stored-agent.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { isPersistedAgentSessionRecord, type AgentSessionRecord } from './agent-session-record' +import { agentSessionRecordFixture } from './agent-session-record.test-fixture' +import { + decodePersistedAgentSessionRecord, + encodeAgentSessionRecord +} from './agent-session-record-stored-form' +import { agentSessionStoredAgents } from './agent-session-stored-agent' +import { CLAUDE_AND_CODEX_STORED_AGENTS } from './agent-session-stored-agent.test-fixture' + +const GROK = { agent: 'grok', handleTransport: 'acp', accountHomeVariable: 'GROK_HOME' } +const WITH_GROK = agentSessionStoredAgents([...CLAUDE_AND_CODEX_STORED_AGENTS.values(), GROK]) + +function grokRecord(transport = 'acp'): AgentSessionRecord { + const record = agentSessionRecordFixture() + return { + ...record, + provider: 'grok', + providerHandleChain: record.providerHandleChain.map((link) => ({ + ...link, + handle: { transport, agent: 'grok', nativeId: 'grok-session-1', providerData: '{"cwd":"/w"}' } + })), + accountHome: { variable: 'GROK_HOME', path: '/home/user/.grok' } + } +} + +describe('records of registered agents', () => { + it('reads a record of any agent the host registered', () => { + const stored = encodeAgentSessionRecord(grokRecord()) + expect(isPersistedAgentSessionRecord(stored, WITH_GROK)).toBe(true) + if (!isPersistedAgentSessionRecord(stored, WITH_GROK)) { + return + } + expect(decodePersistedAgentSessionRecord(stored).record).toEqual(grokRecord()) + }) + + it('sets aside a record of an agent this host did not register', () => { + expect( + isPersistedAgentSessionRecord( + encodeAgentSessionRecord(grokRecord()), + CLAUDE_AND_CODEX_STORED_AGENTS + ) + ).toBe(false) + }) + + it("refuses handles outside the agent's declared transport", () => { + expect( + isPersistedAgentSessionRecord(encodeAgentSessionRecord(grokRecord('other')), WITH_GROK) + ).toBe(false) + }) + + it('keeps reading Claude and Codex records exactly as before', () => { + const stored = encodeAgentSessionRecord(agentSessionRecordFixture()) + expect(isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)).toBe(true) + expect(isPersistedAgentSessionRecord(stored, WITH_GROK)).toBe(true) + expect(JSON.stringify(stored.providerHandleChain[0]?.handle)).toBe( + '{"provider":"claude","sessionId":"provider-session-alpha-1","leafUuid":null}' + ) + }) + + it('refuses a Claude record whose handles another agent owns', () => { + const record = agentSessionRecordFixture() + expect( + isPersistedAgentSessionRecord( + encodeAgentSessionRecord({ ...record, provider: 'codex', accountHome: record.accountHome }), + CLAUDE_AND_CODEX_STORED_AGENTS + ) + ).toBe(false) + }) +}) diff --git a/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts b/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts new file mode 100644 index 000000000000..a54be6e3e723 --- /dev/null +++ b/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { + AgentsParams, + AttachParams, + CreateIntentParams, + CreateSupportParams, + ModelCatalogParams +} from './structured-agent-session-params' + +const ENVELOPE = { + sessionId: 'agent-session-0123456789abcdef0123456789abcdef', + clientOperationId: 'op-1', + expectedRuntimeFence: null, + payloadFingerprint: 'a'.repeat(64) +} + +describe('structured agent params', () => { + it.each(['claude', 'codex', 'grok', 'qwen-code'])('accept the agent id %s', (agent) => { + expect(CreateSupportParams.safeParse({ worktree: 'wt', agent }).success).toBe(true) + expect(ModelCatalogParams.safeParse({ agent }).success).toBe(true) + expect( + CreateIntentParams.safeParse({ envelope: ENVELOPE, worktree: 'wt', agent }).success + ).toBe(true) + }) + + it.each(['', ' claude', 'grok agent', '../codex', 'a'.repeat(65), 7])( + 'refuse what is not an agent id: %s', + (agent) => { + expect(CreateSupportParams.safeParse({ worktree: 'wt', agent }).success).toBe(false) + expect(ModelCatalogParams.safeParse({ agent }).success).toBe(false) + } + ) + + it('keeps attaching by a client-supplied handle to Claude and Codex', () => { + const attach = { + envelope: ENVELOPE, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'wt', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/u/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + } + expect(AttachParams.safeParse(attach).success).toBe(true) + expect(AttachParams.safeParse({ ...attach, provider: 'grok' }).success).toBe(false) + }) + + it('takes nothing for the agent list', () => { + expect(AgentsParams.safeParse({}).success).toBe(true) + expect(AgentsParams.safeParse({ agent: 'grok' }).success).toBe(false) + }) +}) diff --git a/src/shared/structured-native-chat-launch-route.test.ts b/src/shared/structured-native-chat-launch-route.test.ts index 8f5e0f40c2e9..7c64df9144c4 100644 --- a/src/shared/structured-native-chat-launch-route.test.ts +++ b/src/shared/structured-native-chat-launch-route.test.ts @@ -4,7 +4,10 @@ */ import { describe, expect, it } from 'vitest' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from './protocol-version' +import { + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from './protocol-version' import { agentTabsDefaultToNativeChat, prefersStructuredNativeChatByDefault, @@ -107,3 +110,38 @@ describe('per-launch structured feasibility', () => { expect(support({ workspaceKind: 'folder' })).toEqual({ supported: true }) }) }) + +describe('agents beyond Claude and Codex', () => { + const REGISTERED = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ] + + it('are offered only by a host that advertises and lists them', () => { + expect( + support({ agent: 'grok', hostCapabilities: REGISTERED, hostStructuredAgents: ['grok'] }) + ).toEqual({ supported: true }) + }) + + it('are not offered by a host that does not advertise its registered agents', () => { + expect(support({ agent: 'grok', hostStructuredAgents: ['grok'] })).toEqual({ + supported: false, + blocker: 'runtime-capability' + }) + }) + + it('are not offered when the host did not list them', () => { + expect(support({ agent: 'grok', hostCapabilities: REGISTERED })).toEqual({ + supported: false, + blocker: 'agent-without-structured-session' + }) + expect( + support({ agent: 'grok', hostCapabilities: REGISTERED, hostStructuredAgents: ['claude'] }) + ).toEqual({ supported: false, blocker: 'agent-without-structured-session' }) + }) + + it('leave Claude and Codex offered by hosts that predate the capability', () => { + expect(support({ agent: 'claude' })).toEqual({ supported: true }) + expect(support({ agent: 'codex' })).toEqual({ supported: true }) + }) +}) diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index 852bb948e354..61c25e39ad43 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -608,6 +608,46 @@ describe('parseWorkspaceSession', () => { } }) + it('keeps a chat tab of any agent id, and drops only a malformed id', () => { + const chatTab = (id: string, agentSessionAgent: unknown) => ({ + id, + entityId: id, + groupId: 'group1', + worktreeId: 'wt', + contentType: 'agent-session', + agentSessionAgent, + label: 'Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + }) + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { + wt: [ + chatTab('a', 'grok'), + chatTab('b', 'not an agent!'), + chatTab('c', 42), + chatTab('d', 'claude') + ] + } + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.unifiedTabs?.wt.map((tab) => [tab.id, tab.agentSessionAgent])).toEqual([ + ['a', 'grok'], + ['b', undefined], + ['c', undefined], + ['d', 'claude'] + ]) + } + }) + it('degrades an unknown viewMode to the safe default instead of failing parse', () => { const result = parseWorkspaceSession({ activeRepoId: null, diff --git a/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts new file mode 100644 index 000000000000..7ab29c66eea6 --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts @@ -0,0 +1,371 @@ +// A structured agent beyond Claude and Codex, paired across two builds: current code against the +// newest published release. +// +// A host now accepts any agent it registered, publishes those agents, and stores their records. +// Clients and hosts update independently, so each skew must hold before such an agent ships: +// +// - an old client paired with a new host never meets that agent's tab, and the Claude and Codex +// tabs it does meet are what its own build would have published; +// - an old build reading what a new one stored (a tab, a record) keeps every other tab and never +// reads the new agent's record as one of its own; +// - a new client against an old host does not offer the agent, because the capability is absent. +// +// The old side's lists are read from its checkout and the capability removed from them, so this +// stays exercised after a release ships the capability (docs/reference/remote-wire-compatibility.md). + +import { beforeAll, describe, expect, it } from 'vitest' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../src/main/ipc/desktop-renderer-runtime-capabilities' +import { projectSessionTabAgentStatus } from '../../../src/main/runtime/rpc/methods/session-tab-agent-status-projection' +import { agentSessionRecordFixture } from '../../../src/shared/agent-session-record.test-fixture' +import { encodeAgentSessionRecord } from '../../../src/shared/agent-session-record-stored-form' +import type { AgentSessionRecord } from '../../../src/shared/agent-session-record' +import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' +import { + RUNTIME_CAPABILITIES, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../src/shared/protocol-version' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../src/shared/runtime-types' +import { resolveStructuredNativeChatSupport } from '../../../src/shared/structured-native-chat-launch-route' +import { parseWorkspaceSession } from '../../../src/shared/workspace-session-schema' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + resolveBaselineReleaseRef +} from './release-checkout' +import { + loadAgentSessionWireBuild, + WORKING_TREE, + type AgentSessionWireBuild +} from './versioned-agent-session-wire' + +// Why: a cold CI run extracts the baseline checkout before the first pairing. +const SUITE_TIMEOUT_MS = 180_000 + +const NEW_AGENT = 'grok' +const WORKTREE = 'wt-1' + +type Baseline = { + ref: string + wire: AgentSessionWireBuild + desktopClientCapabilities: readonly string[] + /** Releases before the host admitted by client capability alone also took the host's chat + * setting; passing it on is harmless to one that no longer reads it. */ + projectSessionTabAgentStatus: ( + payload: RuntimeMobileSessionTabsSnapshot, + clientKind: 'mobile' | 'runtime', + clientCapabilities: readonly string[], + structuredNativeChatEnabled: boolean + ) => RuntimeMobileSessionTabsSnapshot + parseWorkspaceSession: (raw: unknown) => { ok: boolean; value?: unknown } + isStructuredTab: (tab: unknown) => boolean + isPersistedAgentSessionRecord: (value: unknown) => boolean +} + +let baseline: Baseline +let current: AgentSessionWireBuild + +function member(module: Record, name: string): T { + const value = module[name] + if (value === undefined) { + throw new Error(`the baseline release exports no ${name}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a release module is typed unknown; the name is the same export this build calls, and a missing one throws above. + return value as T +} + +beforeAll(async () => { + const ref = resolveBaselineReleaseRef() + const checkout = await materializeReleaseCheckout(ref) + const [capabilities, projection, schema, tabs, record] = await Promise.all([ + importReleaseCheckoutModule(checkout, 'src/main/ipc/desktop-renderer-runtime-capabilities.ts'), + importReleaseCheckoutModule( + checkout, + 'src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts' + ), + importReleaseCheckoutModule(checkout, 'src/shared/workspace-session-schema.ts'), + importReleaseCheckoutModule( + checkout, + 'src/renderer/src/components/native-chat/structured-agent-session-tabs.ts' + ), + importReleaseCheckoutModule(checkout, 'src/shared/agent-session-record.ts') + ]) + current = await loadAgentSessionWireBuild(WORKING_TREE) + baseline = { + ref, + wire: await loadAgentSessionWireBuild(ref), + desktopClientCapabilities: member(capabilities, 'DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES'), + projectSessionTabAgentStatus: member(projection, 'projectSessionTabAgentStatus'), + parseWorkspaceSession: member(schema, 'parseWorkspaceSession'), + isStructuredTab: member(tabs, 'isStructuredTab'), + isPersistedAgentSessionRecord: member(record, 'isPersistedAgentSessionRecord') + } +}, SUITE_TIMEOUT_MS) + +/** What a desktop too old to render the host's agents advertises: its own list without the + * capability, so the day a release ships it this still describes a client that predates it. */ +function oldDesktopClientCapabilities(): string[] { + return baseline.desktopClientCapabilities.filter( + (capability) => capability !== STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) +} + +/** What a host too old to register agents advertises, derived the same way. */ +function oldHostCapabilities(): string[] { + return baseline.wire.capabilities.filter( + (capability) => capability !== STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) +} + +function chatTab(agent: string, isActive: boolean) { + return { + type: 'agent-session' as const, + id: `agent-session:${agent}-session-1`, + title: `${agent} chat`, + sessionId: `${agent}-session-1`, + agent, + isActive + } +} + +function tabsSnapshot(agents: readonly string[]): RuntimeMobileSessionTabsSnapshot { + const tabs = agents.map((agent, index) => chatTab(agent, index === 0)) + return { + worktree: WORKTREE, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: tabs[0]?.id ?? null, + activeTabType: 'agent-session', + tabGroups: [ + { + id: 'group-1', + activeTabId: tabs[0]?.id ?? null, + tabOrder: tabs.map((tab) => tab.id) + } + ], + tabs + } +} + +function persistedChatTab(id: string, agentSessionAgent: string) { + return { + id, + entityId: id, + groupId: 'group-1', + worktreeId: WORKTREE, + contentType: 'agent-session', + agentSessionAgent, + label: 'Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +/** A workspace session as this build persists it with chats of three agents open. */ +function persistedSession(): unknown { + return { + activeRepoId: null, + activeWorktreeId: WORKTREE, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { + [WORKTREE]: [ + persistedChatTab('chat-claude', 'claude'), + persistedChatTab('chat-new-agent', NEW_AGENT), + persistedChatTab('chat-codex', 'codex') + ] + }, + activeTabTypeByWorktree: { [WORKTREE]: 'agent-session' } + } +} + +function newAgentRecord(): AgentSessionRecord { + const record = agentSessionRecordFixture() + return { + ...record, + provider: NEW_AGENT, + providerHandleChain: record.providerHandleChain.map((link) => ({ + ...link, + handle: { transport: 'acp', agent: NEW_AGENT, nativeId: 'native-session-1' } + })), + accountHome: { variable: 'GROK_HOME', path: '/home/user/.grok' } + } +} + +function unifiedTabsOf(parsed: { ok: boolean; value?: unknown }): Record[] { + const value = parsed.value + if (typeof value !== 'object' || value === null || !('unifiedTabs' in value)) { + return [] + } + const unified = value.unifiedTabs + if (typeof unified !== 'object' || unified === null || !(WORKTREE in unified)) { + return [] + } + const tabs: unknown = Reflect.get(unified, WORKTREE) + return Array.isArray(tabs) ? tabs : [] +} + +describe('a structured agent beyond Claude and Codex, across versions', () => { + it( + 'pairs current code with a real published release', + () => { + expect(baseline.ref).toMatch(/^v?\d/) + // Anti-vacuous: the old desktop still says it reads structured chats, so a withheld tab + // below is this capability's gate answering, not the whole surface being refused. + expect(oldDesktopClientCapabilities()).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + }, + SUITE_TIMEOUT_MS + ) + + describe('old client, new host', () => { + it("never receives the new agent's tab, and its Claude and Codex tabs are what its own build publishes", () => { + const client = oldDesktopClientCapabilities() + const projected = projectSessionTabAgentStatus( + tabsSnapshot([NEW_AGENT, 'claude', 'codex']), + 'runtime', + client + ) + expect(projected.tabs.map((tab) => tab.id)).not.toContain(chatTab(NEW_AGENT, false).id) + expect(projected.tabGroups?.[0]?.tabOrder).not.toContain(chatTab(NEW_AGENT, false).id) + // Same-version reference: the release publishing the same chats to its own client. + const reference = baseline.projectSessionTabAgentStatus( + tabsSnapshot(['claude', 'codex']), + 'runtime', + client, + true + ) + expect(reference.tabs.length).toBe(2) + expect(projected.tabs).toEqual(reference.tabs) + }) + + it('keeps every other tab when it loads a session a newer build saved', () => { + const parsed = baseline.parseWorkspaceSession(persistedSession()) + expect(parsed.ok).toBe(true) + const tabs = unifiedTabsOf(parsed) + expect(tabs.map((tab) => tab.id)).toEqual(['chat-claude', 'chat-new-agent', 'chat-codex']) + expect(tabs.map((tab) => tab.agentSessionAgent)).toEqual(['claude', undefined, 'codex']) + // The tab it cannot place stays listed and mounts no chat pane. + expect(tabs.map((tab) => baseline.isStructuredTab(tab))).toEqual([true, false, true]) + // This build reads the same saved session with the agent intact. + expect( + unifiedTabsOf(parseWorkspaceSession(persistedSession())).map((tab) => tab.agentSessionAgent) + ).toEqual(['claude', NEW_AGENT, 'codex']) + }) + + it("sets aside the new agent's record and reads Claude and Codex records as before", () => { + expect( + baseline.isPersistedAgentSessionRecord(encodeAgentSessionRecord(newAgentRecord())) + ).toBe(false) + const claude = agentSessionRecordFixture() + const codex: AgentSessionRecord = { + ...claude, + provider: 'codex', + providerHandleChain: claude.providerHandleChain.map((link) => ({ + ...link, + handle: codexProviderHandle('thread-1') + })), + accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' } + } + for (const record of [claude, codex]) { + expect(baseline.isPersistedAgentSessionRecord(encodeAgentSessionRecord(record))).toBe(true) + } + }) + }) + + describe('new client, old host', () => { + it('does not offer the new agent to a host without the capability', () => { + const host = oldHostCapabilities() + expect( + resolveStructuredNativeChatSupport({ + agent: NEW_AGENT, + executionHostId: 'local', + hostCapabilities: host, + hostStructuredAgents: [NEW_AGENT], + workspaceKind: 'git-worktree' + }) + ).toEqual({ supported: false, blocker: 'runtime-capability' }) + // Claude and Codex are offered exactly when the old host serves structured chats at all. + for (const agent of ['claude', 'codex'] as const) { + expect( + resolveStructuredNativeChatSupport({ + agent, + executionHostId: 'local', + hostCapabilities: host, + workspaceKind: 'git-worktree' + }).supported + ).toBe(host.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) + } + }) + + it('would be refused by an old host that does not advertise the capability', async () => { + if ( + baseline.wire.capabilities.includes( + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) + ) { + return + } + const replies: { ok: boolean; result?: unknown }[] = [] + await baseline.wire.createDispatcher(createSupportRuntime()).dispatchStreaming( + { + id: 'create-support', + authToken: 'cross-version-token', + method: 'agentSession.createSupport', + params: { worktree: `id:${WORKTREE}`, agent: NEW_AGENT } + }, + (raw) => replies.push(JSON.parse(raw)), + { clientKind: 'runtime', clientCapabilities: baseline.wire.capabilities } + ) + expect(replies).toHaveLength(1) + expect(replies[0]?.ok).toBe(false) + }) + }) + + describe('Claude and Codex, both ways', () => { + it('are accepted by both builds as before', async () => { + for (const build of [current, baseline.wire]) { + for (const agent of ['claude', 'codex']) { + const replies: { ok: boolean; result?: unknown }[] = [] + await build.createDispatcher(createSupportRuntime()).dispatchStreaming( + { + id: `create-support-${agent}`, + authToken: 'cross-version-token', + method: 'agentSession.createSupport', + params: { worktree: `id:${WORKTREE}`, agent } + }, + (raw) => replies.push(JSON.parse(raw)), + { clientKind: 'runtime', clientCapabilities: [...RUNTIME_CAPABILITIES] } + ) + expect(replies, `${build.label}: ${agent}`).toEqual([ + expect.objectContaining({ ok: true, result: { supported: true } }) + ]) + } + } + }) + + it("reach today's desktop unchanged from a new host", () => { + const payload = tabsSnapshot(['claude', 'codex']) + expect( + projectSessionTabAgentStatus( + payload, + 'runtime', + DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES + ) + ).toBe(payload) + }) + }) +}) + +/** Enough of a runtime for createSupport to answer: the verdict itself is stubbed. */ +function createSupportRuntime(): unknown { + return { + getRuntimeId: () => 'runtime-1', + getClientSettings: () => ({ experimentalStructuredNativeChat: true }), + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + structuredAgentSessionLaunchSeedOptions: () => undefined + } +} diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 681c65f50c12..0a7d2d9ee38b 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 @@ -1,6 +1,7 @@ import { vi } from 'vitest' import type { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' +import { CODEX_STRUCTURED_AGENT } from '../../../src/main/codex/codex-structured-agent-definition' import { AGENT_SESSION_TURN_ITEM_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY @@ -93,7 +94,8 @@ export function structuredHostStub( // No opening emit, unlike the status feed above: a completion is an edge, so this stream // opens empty and a subscriber that was away has missed what passed. subscribeTurnCompletions: vi.fn(() => () => undefined), - unsubscribe: vi.fn() + unsubscribe: vi.fn(), + agentDefinitions: vi.fn(() => [CODEX_STRUCTURED_AGENT]) } } 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 7eb4920deec3..3377f56bd0c5 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 @@ -183,7 +183,13 @@ export const STRUCTURED_CALLS: { }, // Teardown runs through the runtime's subscription registry rather than the // host, so its reply is the only signal that the gate opened. - { method: 'agentSession.unsubscribe', hostMethod: null, result: { unsubscribed: true } } + { method: 'agentSession.unsubscribe', hostMethod: null, result: { unsubscribed: true } }, + // The host's registered agents, each with its declared capability record. + { + method: 'agentSession.agents', + hostMethod: 'agentDefinitions', + result: { agents: [{ agent: 'codex', capabilities: { compact: true } }] } + } ] export function envelope(args: { @@ -294,6 +300,7 @@ export function paramsFor(method: string): unknown { case 'agentSession.hold': case 'agentSession.release': return { sessionId: SESSION, holderId: 'surface-1' } + case 'agentSession.agents': case 'agentSession.restartResumable': case 'agentSession.restartResumableDismiss': case 'agentSession.restartResume': From 6b8c332df65dc37aed1a363ffa5bf239c60882be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:39:11 -0700 Subject: [PATCH 045/269] refactor(native-chat): let the record store decide which agents' tabs exist --- ...ca-runtime-restore-structured-agent-session-tabs-once.ts | 5 +---- .../runtime/orca-runtime-structured-session-restore.test.ts | 6 ------ src/main/runtime/rpc/methods/structured-agent-session.ts | 4 ++-- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 425c07e0502b..c99a62e1d196 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -74,11 +74,8 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu }) } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() + // Every session here is of an agent this host registered: its store holds no other agent's records. const restored = (host?.listSessionTabs() ?? []).flatMap((session) => { - // A tab names an agent only this host registered; the record store reads no other's records. - if (!host?.agentDefinition(session.agent)) { - return [] - } let sessionId = session.sessionId while (sessionId.startsWith('agent-session:')) { sessionId = sessionId.slice('agent-session:'.length) diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index b1ff4fc5ece1..92c39dda2835 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { OrcaRuntimeService } from './orca-runtime' -import { claudeAndCodexDefinition } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' afterEach(() => setStructuredAgentSessionHost(null)) @@ -77,7 +76,6 @@ describe('structured session cold restoration', () => { setStructuredAgentSessionHost({ reconcileRestartLeases, restoreReadableSessions, - agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [] } as never) @@ -142,7 +140,6 @@ describe('structured session cold restoration', () => { sessionIds: ['session-survives-rollback'] }), restoreReadableSessions, - agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [] } as never) @@ -197,7 +194,6 @@ describe('structured session cold restoration', () => { reconcileRestartLeases: async () => undefined, getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }), restoreReadableSessions, - agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [] } as never) @@ -232,7 +228,6 @@ describe('structured session cold restoration', () => { restoreReadableSessions: async () => undefined, close: closeStructuredSession, setSessionTabVisibility, - agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [ { sessionId: 'agent-session:agent-session:restored-session', @@ -346,7 +341,6 @@ describe('structured session cold restoration', () => { setStructuredAgentSessionHost({ reconcileRestartLeases: async () => undefined, restoreReadableSessions: async () => undefined, - agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [ { sessionId: 'agent-session:agent-session:restored-claude', diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 4ee06cd83542..f829560697d4 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -85,8 +85,8 @@ async function resolveClientSuppliedAttach(params: z.infer, const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params const attachParams = { ...attachWithoutAgent, - provider: params.provider, - agent: params.agent + provider: params.provider as 'claude' | 'codex', + agent: params.agent as 'claude' | 'codex' } as AgentSessionAttachParams return { host, attachParams } } From 745ace695aab2918bd949342cfa12e1f2e4105e8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:42:04 -0700 Subject: [PATCH 046/269] test(native-chat): declare the pilot test agent's storage --- .../structured-agent-session-adapter-router-registry.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts index 516296a06d5e..356abcd6ccba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts @@ -59,6 +59,8 @@ function fakeAdapter( /** An agent this build does not ship, declared the way a new adapter would declare itself. */ const PILOT: StructuredAgentDefinition = { agent: 'grok', + handleTransport: 'acp', + accountHomeVariable: 'GROK_HOME', capabilities: { ...NO_AGENT_SESSION_CAPABILITIES, steering: 'queue', From c8e42c4ae262157b13a1d902064c74c0e181a7e5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:51:47 -0700 Subject: [PATCH 047/269] test(native-chat): read the old build's saved tabs through a parsed shape --- ...ross-version-registered-agents.unit.test.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts index 7ab29c66eea6..997ae2ea1469 100644 --- a/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts @@ -14,6 +14,7 @@ // stays exercised after a release ships the capability (docs/reference/remote-wire-compatibility.md). import { beforeAll, describe, expect, it } from 'vitest' +import { z } from 'zod' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../src/main/ipc/desktop-renderer-runtime-capabilities' import { projectSessionTabAgentStatus } from '../../../src/main/runtime/rpc/methods/session-tab-agent-status-projection' import { agentSessionRecordFixture } from '../../../src/shared/agent-session-record.test-fixture' @@ -196,17 +197,14 @@ function newAgentRecord(): AgentSessionRecord { } } +/** A parsed session's chat tabs in the test worktree, read through a named shape. */ +const parsedSessionTabs = z.object({ + unifiedTabs: z.record(z.string(), z.array(z.record(z.string(), z.unknown()))) +}) + function unifiedTabsOf(parsed: { ok: boolean; value?: unknown }): Record[] { - const value = parsed.value - if (typeof value !== 'object' || value === null || !('unifiedTabs' in value)) { - return [] - } - const unified = value.unifiedTabs - if (typeof unified !== 'object' || unified === null || !(WORKTREE in unified)) { - return [] - } - const tabs: unknown = Reflect.get(unified, WORKTREE) - return Array.isArray(tabs) ? tabs : [] + const session = parsedSessionTabs.safeParse(parsed.value) + return session.success ? (session.data.unifiedTabs[WORKTREE] ?? []) : [] } describe('a structured agent beyond Claude and Codex, across versions', () => { From ca7f95ca98e4791bbc8da4efb5a09b0106f6cad2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:46:52 -0700 Subject: [PATCH 048/269] Translate ACP session traffic into shared timeline events --- src/main/acp/acp-context-usage.ts | 57 ++++ src/main/acp/acp-dialects/acp-dialect.ts | 28 ++ src/main/acp/acp-dialects/grok-dialect.ts | 88 ++++++ src/main/acp/acp-dialects/grok-requests.ts | 124 ++++++++ src/main/acp/acp-session-update.ts | 87 ++++++ src/main/acp/acp-timeline-dialect.test.ts | 188 ++++++++++++ .../acp/acp-timeline-fixture.test-support.ts | 168 ++++++++++ src/main/acp/acp-timeline-generic.test.ts | 269 ++++++++++++++++ src/main/acp/acp-timeline-recordings.test.ts | 138 +++++++++ src/main/acp/acp-timeline-requests.ts | 39 +++ src/main/acp/acp-timeline-translator.ts | 286 ++++++++++++++++++ src/main/acp/acp-tool-timeline.ts | 170 +++++++++++ src/main/acp/fixtures/s1-basic.jsonl | 11 + src/main/acp/fixtures/s2-permission.jsonl | 20 ++ src/main/acp/fixtures/s3-cancel.jsonl | 13 + src/main/acp/fixtures/s4-resume.jsonl | 23 ++ src/main/acp/fixtures/s5-plan-approved.jsonl | 15 + src/main/acp/fixtures/s6-background.jsonl | 11 + 18 files changed, 1735 insertions(+) create mode 100644 src/main/acp/acp-context-usage.ts create mode 100644 src/main/acp/acp-dialects/acp-dialect.ts create mode 100644 src/main/acp/acp-dialects/grok-dialect.ts create mode 100644 src/main/acp/acp-dialects/grok-requests.ts create mode 100644 src/main/acp/acp-session-update.ts create mode 100644 src/main/acp/acp-timeline-dialect.test.ts create mode 100644 src/main/acp/acp-timeline-fixture.test-support.ts create mode 100644 src/main/acp/acp-timeline-generic.test.ts create mode 100644 src/main/acp/acp-timeline-recordings.test.ts create mode 100644 src/main/acp/acp-timeline-requests.ts create mode 100644 src/main/acp/acp-timeline-translator.ts create mode 100644 src/main/acp/acp-tool-timeline.ts create mode 100644 src/main/acp/fixtures/s1-basic.jsonl create mode 100644 src/main/acp/fixtures/s2-permission.jsonl create mode 100644 src/main/acp/fixtures/s3-cancel.jsonl create mode 100644 src/main/acp/fixtures/s4-resume.jsonl create mode 100644 src/main/acp/fixtures/s5-plan-approved.jsonl create mode 100644 src/main/acp/fixtures/s6-background.jsonl diff --git a/src/main/acp/acp-context-usage.ts b/src/main/acp/acp-context-usage.ts new file mode 100644 index 000000000000..e290d1056d05 --- /dev/null +++ b/src/main/acp/acp-context-usage.ts @@ -0,0 +1,57 @@ +import { z } from 'zod' +import type { AgentSessionContextUsage } from '../../shared/agent-session-context-usage' +import type { Usage, UsageUpdate } from './generated/acp-protocol.generated' + +export function acpWindowUsage(update: UsageUpdate, at: number): AgentSessionContextUsage { + return { + window: { tokens: update.size, capturedAt: at }, + used: { + kind: 'estimate', + capturedAt: at, + usage: { + inputTokens: update.used, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0 + } + } + } +} + +export function acpResponseUsage(usage: Usage, at: number): AgentSessionContextUsage { + return tokenEstimate( + usage.inputTokens, + usage.outputTokens, + usage.cachedReadTokens ?? 0, + usage.cachedWriteTokens ?? 0, + at + ) +} + +export function tokenEstimate( + input: number, + output: number, + cachedRead: number, + cachedWrite: number, + at: number +): AgentSessionContextUsage { + return { + used: { + kind: 'estimate', + capturedAt: at, + usage: { + // ACP input includes cached tokens; the shared estimate adds its cache fields. + inputTokens: Math.max(0, input - cachedRead - cachedWrite), + cacheReadInputTokens: cachedRead, + cacheCreationInputTokens: cachedWrite, + outputTokens: output + } + } + } +} + +export const acpNotificationEnvelopeSchema = z.looseObject({ + sessionId: z.string(), + update: z.looseObject({ sessionUpdate: z.string() }), + _meta: z.looseObject({ isReplay: z.boolean().optional() }).nullish() +}) diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts new file mode 100644 index 000000000000..c4bcadc13232 --- /dev/null +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -0,0 +1,28 @@ +import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' +import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' +import type { ProviderTimelineRequestBody } from '../../native-chat/agent-session-timeline/provider-timeline-event' +import type { ToolCallUpdate } from '../generated/acp-protocol.generated' + +export type AcpRequestPresentation = { + body: ProviderTimelineRequestBody + reply(response: AgentSessionPromptResponse | null): unknown +} + +export type AcpDialectNotification = { + turn?: string + agentInitiated?: boolean + replay?: boolean + at?: number + end?: { stopReason: string; durationMs?: number } + usage?: AgentSessionContextUsage +} + +/** Hooks only interpret provider extensions; lifecycle and row identity stay shared. */ +export type AcpDialect = { + toolName?(update: ToolCallUpdate): string | undefined + notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined + promptUsage?(result: unknown, at: number): AgentSessionContextUsage | undefined + request?(method: string, params: unknown): AcpRequestPresentation | undefined +} + +export const GENERIC_ACP_DIALECT: AcpDialect = {} diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts new file mode 100644 index 000000000000..48b1233d7f4e --- /dev/null +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -0,0 +1,88 @@ +import { z } from 'zod' +import { acpNotificationEnvelopeSchema, tokenEstimate } from '../acp-context-usage' +import type { AcpDialect } from './acp-dialect' +import { grokRequest } from './grok-requests' + +const tokenCount = z.number().int().nonnegative() +const usageSchema = z.looseObject({ + inputTokens: tokenCount, + outputTokens: tokenCount.default(0), + cachedReadTokens: tokenCount.default(0), + cacheCreationTokens: tokenCount.default(0) +}) +const promptUsageSchema = z.object({ _meta: z.looseObject({ usage: usageSchema.optional() }) }) +const toolMetaSchema = z.object({ 'x.ai/tool': z.object({ name: z.string().min(1) }) }) +const turnMetaSchema = z.looseObject({ + promptId: z.string().optional(), + turnStartMs: tokenCount.optional() +}) +const completionSchema = z.looseObject({ + sessionUpdate: z.literal('turn_completed'), + prompt_id: z.string(), + stop_reason: z.string(), + usage: usageSchema.optional(), + elapsed_ms: tokenCount.optional() +}) + +function usageFrom(value: unknown, at: number) { + const parsed = usageSchema.safeParse(value) + if (!parsed.success) { + return undefined + } + const usage = parsed.data + return tokenEstimate( + usage.inputTokens, + usage.outputTokens, + usage.cachedReadTokens, + usage.cacheCreationTokens, + at + ) +} + +export const GROK_ACP_DIALECT: AcpDialect = { + toolName: (update) => { + const parsed = toolMetaSchema.safeParse(update._meta) + return parsed.success ? parsed.data['x.ai/tool'].name : undefined + }, + request: grokRequest, + promptUsage: (result, at) => { + const parsed = promptUsageSchema.safeParse(result) + return parsed.success ? usageFrom(parsed.data._meta.usage ?? parsed.data._meta, at) : undefined + }, + notification: (method, params, at) => { + if ( + !['session/update', '_x.ai/session_notification', '_x.ai/session/update'].includes(method) + ) { + return undefined + } + const parsed = acpNotificationEnvelopeSchema.safeParse(params) + if (!parsed.success) { + return undefined + } + const envelope = parsed.data + const meta = turnMetaSchema.safeParse(envelope._meta) + const completion = completionSchema.safeParse(envelope.update) + const turn = completion.success + ? completion.data.prompt_id + : meta.success + ? meta.data.promptId + : undefined + const usage = completion.success ? usageFrom(completion.data.usage, at) : undefined + return { + ...(turn === undefined ? {} : { turn, agentInitiated: turn.startsWith('task-completed-') }), + ...(meta.success && meta.data.turnStartMs !== undefined ? { at: meta.data.turnStartMs } : {}), + replay: envelope._meta?.isReplay === true, + ...(completion.success + ? { + end: { + stopReason: completion.data.stop_reason, + ...(completion.data.elapsed_ms === undefined + ? {} + : { durationMs: completion.data.elapsed_ms }) + } + } + : {}), + ...(usage === undefined ? {} : { usage }) + } + } +} diff --git a/src/main/acp/acp-dialects/grok-requests.ts b/src/main/acp/acp-dialects/grok-requests.ts new file mode 100644 index 000000000000..7f6c0d15f950 --- /dev/null +++ b/src/main/acp/acp-dialects/grok-requests.ts @@ -0,0 +1,124 @@ +import { z } from 'zod' +import { + isValidAgentSessionQuestionAnswers, + legacyAgentSessionQuestionAnswers +} from '../../../shared/agent-session-question-answer' +import { AcpRpcError } from '../acp-errors' +import { pendingAcpResolution } from '../acp-timeline-requests' +import type { AcpRequestPresentation } from './acp-dialect' + +const questionRequestSchema = z.object({ + sessionId: z.string(), + toolCallId: z.string(), + questions: z + .array( + z.object({ + question: z.string(), + multiSelect: z.boolean().nullish(), + options: z.array(z.object({ label: z.string(), description: z.string().optional() })) + }) + ) + .min(1) +}) +const planRequestSchema = z.object({ + sessionId: z.string(), + toolCallId: z.string(), + planContent: z.string() +}) + +function questionRequest(params: unknown): AcpRequestPresentation { + const parsed = questionRequestSchema.safeParse(params) + if (!parsed.success) { + throw new AcpRpcError(-32602, 'Invalid agent question request') + } + const questions = parsed.data.questions.map((question, index) => ({ + id: `q${index + 1}`, + question: question.question, + multiSelect: question.multiSelect ?? false, + options: question.options.map((option, optionIndex) => ({ + id: `o${optionIndex + 1}`, + label: option.label, + ...(option.description === undefined ? {} : { description: option.description }) + })), + freeTextQuestionId: `q${index + 1}` + })) + const body = { + kind: 'question' as const, + question: questions[0]!.question, + options: questions[0]!.options, + questions, + resolution: pendingAcpResolution + } + return { + body, + reply: (response) => { + if (response === null) { + return { outcome: 'cancelled' } + } + const answers = + response.kind === 'answers' + ? response.answers + : legacyAgentSessionQuestionAnswers(body, response.optionId) + if (!answers || !isValidAgentSessionQuestionAnswers(questions, answers)) { + throw new AcpRpcError(-32602, 'Question answer must answer every offered question') + } + return { + outcome: 'accepted', + answers: Object.fromEntries( + questions.map((question) => { + const answer = answers.find((entry) => entry.questionId === question.id)! + const values = answer.optionIds.map( + (id) => question.options.find((option) => option.id === id)!.label + ) + if (answer.other?.trim()) { + values.push(answer.other.trim()) + } + return [question.question, question.multiSelect ? values : values[0]] + }) + ) + } + } + } +} + +function planRequest(params: unknown): AcpRequestPresentation { + const parsed = planRequestSchema.safeParse(params) + if (!parsed.success) { + throw new AcpRpcError(-32602, 'Invalid agent plan approval request') + } + return { + body: { + kind: 'approval', + title: 'Approve plan', + detail: null, + subject: { kind: 'plan', text: parsed.data.planContent }, + options: [ + { id: 'approved', label: 'Approve plan' }, + { id: 'request_changes', label: 'Request changes' } + ], + resolution: pendingAcpResolution + }, + reply: (response) => { + if (response === null) { + return { outcome: 'abandoned' } + } + if ( + response.kind !== 'option' || + !['approved', 'request_changes'].includes(response.optionId) + ) { + throw new AcpRpcError(-32602, 'Plan answer must select an offered option') + } + return { outcome: response.optionId } + } + } +} + +export function grokRequest(method: string, params: unknown): AcpRequestPresentation | undefined { + if (method === '_x.ai/ask_user_question') { + return questionRequest(params) + } + if (method === '_x.ai/exit_plan_mode') { + return planRequest(params) + } + return undefined +} diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts new file mode 100644 index 000000000000..516b95d465ad --- /dev/null +++ b/src/main/acp/acp-session-update.ts @@ -0,0 +1,87 @@ +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { acpWindowUsage } from './acp-context-usage' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import type { AcpToolTimeline } from './acp-tool-timeline' +import type { SessionNotification } from './generated/acp-protocol.generated' + +export function acpSessionUpdate( + notification: SessionNotification, + turn: string | undefined, + at: number, + replay: boolean, + tools: AcpToolTimeline, + dialect: AcpDialect +): ProviderTimelineEvent[] { + const update = notification.update + const join = turn === undefined ? {} : { join: { turn } } + switch (update.sessionUpdate) { + case 'agent_message_chunk': + case 'agent_thought_chunk': + return update.content.type === 'text' + ? [ + { + type: 'text.delta', + item: update.messageId + ? { id: `message:${update.messageId}` } + : { stream: update.sessionUpdate }, + channel: update.sessionUpdate === 'agent_thought_chunk' ? 'reasoning' : 'assistant', + text: update.content.text, + ...join + } + ] + : [{ type: 'provider.frame', frameKind: update.sessionUpdate, payload: update, ...join }] + case 'user_message_chunk': + return replay && update.content.type === 'text' + ? [ + { + type: 'item.update', + item: `replay-user:${turn}:${update.messageId ?? ''}`, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: update.content.text }] + }, + ...join + } + ] + : [] + case 'tool_call': + case 'tool_call_update': + return tools.translate(update, dialect, turn) + case 'plan': + return [ + { + type: 'item.update', + item: `plan:${turn ?? 'thread'}`, + body: { + kind: 'status', + presentation: 'plan-document', + text: boundInlineText( + update.entries + .map( + (entry) => + `- [${entry.status === 'completed' ? 'x' : entry.status === 'in_progress' ? '~' : ' '}] ${entry.content}` + ) + .join('\n'), + DEFAULT_JOURNAL_PAYLOAD_LIMITS + ).text + }, + ...join + } + ] + case 'usage_update': + return [{ type: 'context.usage', usage: acpWindowUsage(update, at), ...join }] + case 'available_commands_update': + case 'current_mode_update': + case 'config_option_update': + case 'session_info_update': + // These feed commands, options and the session title, outside the timeline. + return [] + default: + return [{ type: 'provider.frame', frameKind: update.sessionUpdate, payload: update, ...join }] + } +} diff --git a/src/main/acp/acp-timeline-dialect.test.ts b/src/main/acp/acp-timeline-dialect.test.ts new file mode 100644 index 000000000000..c5b3b59087de --- /dev/null +++ b/src/main/acp/acp-timeline-dialect.test.ts @@ -0,0 +1,188 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' +import { AcpTimelineTranslator } from './acp-timeline-translator' + +afterEach(closeProviderTimelineRigs) + +async function dialectRig() { + const rig = await openProviderTimelineRig() + const translator = new AcpTimelineTranslator({ + sessionId: 'provider-1', + journalItems: () => rig.journal.snapshot().items, + dialect: GROK_ACP_DIALECT + }) + const apply = (events: ProviderTimelineEvent[]) => { + for (const event of events) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + const notification = (method: string, params: unknown) => + apply(translator.notification(method, params, 1100)) + return { rig, translator, apply, notification } +} + +const question = { + sessionId: 'provider-1', + toolCallId: 'ask-1', + questions: [ + { + question: 'Choose a test?', + multiSelect: null, + options: [{ label: 'Unit', description: 'Fast tests' }, { label: 'Integration' }] + } + ] +} + +describe('ACP dialect request and turn boundaries', () => { + it('preserves provider permission labels, validates selected ids, and cancels unanswered rows on turn end', async () => { + const { rig, translator, apply } = await dialectRig() + apply(translator.openPrompt('send-1', 1000)) + const request = translator.request( + 'session/request_permission', + { + sessionId: 'provider-1', + toolCall: { toolCallId: 'call-1', title: 'Write example' }, + options: [ + { optionId: 'custom-yes', name: 'Yes, once', kind: 'allow_once' }, + { optionId: 'custom-no', name: 'Keep the file', kind: 'reject_once' } + ] + }, + 0 + ) + apply(request.events) + expect(request.presentation?.body).toMatchObject({ + options: [ + { id: 'custom-yes', label: 'Yes, once' }, + { id: 'custom-no', label: 'Keep the file' } + ] + }) + expect(request.presentation?.reply(null)).toEqual({ outcome: { outcome: 'cancelled' } }) + expect(() => request.presentation?.reply({ kind: 'option', optionId: 'allow-once' })).toThrow( + 'offered option' + ) + apply(translator.promptResult('send-1', { stopReason: 'cancelled' }, 1200)) + expect((await rig.rows()).find((row) => row.body.kind === 'approval')?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + }) + + it('encodes labels and free text under the question text, validates grouped choices, and gives exact plan replies', async () => { + const { translator } = await dialectRig() + const ask = translator.request('_x.ai/ask_user_question', question, 0).presentation! + expect( + ask.reply({ kind: 'answers', answers: [{ questionId: 'q1', optionIds: ['o2'] }] }) + ).toEqual({ outcome: 'accepted', answers: { 'Choose a test?': 'Integration' } }) + expect( + ask.reply({ kind: 'answers', answers: [{ questionId: 'q1', optionIds: [], other: 'Smoke' }] }) + ).toEqual({ outcome: 'accepted', answers: { 'Choose a test?': 'Smoke' } }) + expect(ask.reply(null)).toEqual({ outcome: 'cancelled' }) + expect(() => ask.reply({ kind: 'answers', answers: [] })).toThrow('every offered question') + expect(() => + ask.reply({ kind: 'answers', answers: [{ questionId: 'q1', optionIds: ['o1', 'o2'] }] }) + ).toThrow('every offered question') + const grouped = translator.request( + '_x.ai/ask_user_question', + { + ...question, + questions: [ + { ...question.questions[0], multiSelect: true }, + { question: 'Another?', options: [{ label: 'Yes' }] } + ] + }, + 1 + ).presentation! + expect( + grouped.reply({ + kind: 'answers', + answers: [ + { questionId: 'q1', optionIds: ['o1', 'o2'] }, + { questionId: 'q2', optionIds: ['o1'] } + ] + }) + ).toEqual({ + outcome: 'accepted', + answers: { 'Choose a test?': ['Unit', 'Integration'], 'Another?': 'Yes' } + }) + const plan = translator.request( + '_x.ai/exit_plan_mode', + { sessionId: 'provider-1', toolCallId: 'plan-1', planContent: 'Plan' }, + 2 + ).presentation! + expect(plan.reply({ kind: 'option', optionId: 'approved' })).toEqual({ outcome: 'approved' }) + expect(plan.reply({ kind: 'option', optionId: 'request_changes' })).toEqual({ + outcome: 'request_changes' + }) + expect(plan.reply(null)).toEqual({ outcome: 'abandoned' }) + expect(() => plan.reply({ kind: 'option', optionId: 'allow' })).toThrow('offered option') + }) + + it('keeps client prompt settlement on the response, and never gives an autonomous turn invented success', async () => { + const { rig, translator, apply, notification } = await dialectRig() + apply(translator.openPrompt('send-1', 1000)) + notification('_x.ai/session_notification', { + sessionId: 'provider-1', + update: { sessionUpdate: 'turn_completed', prompt_id: 'regular', stop_reason: 'end_turn' } + }) + expect((await rig.turns())[0]!.state).toBe('running') + apply(translator.promptResult('send-1', { stopReason: 'cancelled' }, 1200)) + notification('session/update', { + sessionId: 'provider-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Autonomous' } + }, + _meta: { promptId: 'task-completed-1' } + }) + notification('_x.ai/session_notification', { + sessionId: 'provider-1', + update: { + sessionUpdate: 'turn_completed', + prompt_id: 'task-completed-1', + stop_reason: 'future_stop' + } + }) + const turns = await rig.turns() + expect(turns[0]!.outcome).toBe('cancellation') + expect(turns[1]).toMatchObject({ state: 'completed' }) + expect(turns[1]).not.toHaveProperty('outcome') + }) + + it('keeps live autonomous output while suppressing marked replay during load', async () => { + const { rig, translator, apply, notification } = await dialectRig() + apply(translator.openPrompt('send-1', 1000)) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) + await rig.rows() + translator.beginLoad() + notification('session/update', { + sessionId: 'provider-1', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Old' } }, + _meta: { isReplay: true, promptId: 'old' } + }) + notification('session/update', { + sessionId: 'provider-1', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Live' } }, + _meta: { promptId: 'task-completed-live' } + }) + notification('_x.ai/session_notification', { + sessionId: 'provider-1', + update: { + sessionUpdate: 'turn_completed', + prompt_id: 'task-completed-live', + stop_reason: 'end_turn' + } + }) + apply(translator.finishLoad(1300)) + expect( + (await rig.rows()).flatMap((row) => + row.body.kind === 'message' ? [messageText(row.body)] : [] + ) + ).toEqual(['Live']) + expect(await rig.turns()).toHaveLength(2) + }) +}) diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts new file mode 100644 index 000000000000..a019328a9373 --- /dev/null +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -0,0 +1,168 @@ +import { readFile } from 'node:fs/promises' +import { z } from 'zod' +import { expect } from 'vitest' +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + legacyAgentSessionSelectedOptionId, + type AgentSessionPromptResponse +} from '../../shared/agent-session-question-answer' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { openProviderTimelineRig } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' +import type { AcpRequestPresentation } from './acp-dialects/acp-dialect' +import { AcpTimelineTranslator } from './acp-timeline-translator' +import { PromptResponseSchema } from './generated/acp-protocol.generated' + +const frameSchema = z.object({ + direction: z.enum(['in', 'out']), + process: z.string(), + message: z.object({ + id: z.union([z.string(), z.number()]).optional(), + method: z.string().optional(), + params: z.unknown().optional(), + result: z.unknown().optional() + }) +}) +export type AcpFixtureFrame = z.infer +const replySchema = z.object({ + outcome: z.union([ + z.string(), + z.object({ outcome: z.string(), optionId: z.string().optional() }) + ]), + answers: z.record(z.string(), z.string()).optional() +}) + +export async function readAcpFixture(name: string): Promise { + const text = await readFile(new URL(`./fixtures/${name}.jsonl`, import.meta.url), 'utf8') + return text + .trim() + .split('\n') + .map((line) => frameSchema.parse(JSON.parse(line))) +} + +function fixtureAnswer( + presentation: AcpRequestPresentation, + result: unknown +): AgentSessionPromptResponse | null { + const reply = replySchema.parse(result) + if (typeof reply.outcome === 'object') { + return reply.outcome.optionId ? { kind: 'option', optionId: reply.outcome.optionId } : null + } + if (presentation.body.kind === 'approval') { + return { kind: 'option', optionId: reply.outcome } + } + return { + kind: 'answers', + answers: (presentation.body.questions ?? []).map((question) => { + const value = reply.answers?.[question.question] + return { + questionId: question.id, + optionIds: question.options + .filter((option) => option.label === value) + .map((option) => option.id) + } + }) + } +} + +/** Captured frames go through translation, assembly and the real serialized journal. */ +export async function openAcpFixtureRig() { + const rig = await openProviderTimelineRig() + let providerSessionId = 'session-1' + const translator = () => + new AcpTimelineTranslator({ + sessionId: providerSessionId, + journalItems: () => rig.journal.snapshot().items, + dialect: GROK_ACP_DIALECT + }) + let lane = translator() + const requests = new Map< + string | number, + { request: string; presentation: AcpRequestPresentation } + >() + const apply = (events: ProviderTimelineEvent[]) => { + for (const event of events) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + const feed = async (frames: AcpFixtureFrame[]) => { + for (const [index, frame] of frames.entries()) { + const { message, direction } = frame + const at = 1000 + index + if (direction === 'out' && message.method === 'session/load') { + await rig.rows() + lane.beginLoad() + } else if (direction === 'out' && message.method === 'session/prompt') { + const session = z.object({ sessionId: z.string() }).parse(message.params) + if (providerSessionId !== session.sessionId) { + providerSessionId = session.sessionId + lane = translator() + } + apply(lane.openPrompt(`${frame.process}:${message.id}`, at)) + } else if (direction === 'in' && message.method && message.id !== undefined) { + const request = lane.request(message.method, message.params, message.id) + apply(request.events) + const opened = request.events.find((event) => event.type === 'request.open') + if (opened && request.presentation) { + requests.set(message.id, { request: opened.request, presentation: request.presentation }) + } + } else if (direction === 'out' && message.id !== undefined && !message.method) { + const request = requests.get(message.id) + if (!request) { + continue + } + const response = fixtureAnswer(request.presentation, message.result) + expect(request.presentation.reply(response)).toEqual(message.result) + const row = (await rig.rows()).find( + (item) => + item.body.kind === request.presentation.body.kind && + (item.body.kind === 'approval' || item.body.kind === 'question') && + item.body.resolution.state === 'pending' + ) + const identity = row ? parseAgentJournalItemKey(row.itemId) : null + if (!row || !identity || (row.body.kind !== 'approval' && row.body.kind !== 'question')) { + throw new Error('Missing request row') + } + await rig.journal.appendItem( + identity, + { + ...row.body, + resolution: { + state: response ? 'resolved' : 'cancelled', + selectedOptionId: + response?.kind === 'option' + ? response.optionId + : row.body.kind === 'question' && response?.kind === 'answers' + ? legacyAgentSessionSelectedOptionId(row.body, response.answers) + : null, + ...(response?.kind === 'answers' ? { answers: response.answers } : {}), + resolvedBy: 'fixture-client', + resolvedAt: at + } + }, + { fence: 1 } + ) + requests.delete(message.id) + } else if (direction === 'in' && message.method) { + apply(lane.notification(message.method, message.params, at)) + } else if (direction === 'in') { + const result = PromptResponseSchema.safeParse(message.result) + if (result.success) { + apply(lane.promptResult(`${frame.process}:${message.id}`, result.data, at)) + } + } + } + return rig.rows() + } + return { + rig, + apply, + feed, + lane: () => lane, + restart: () => { + rig.assembler = rig.restart({ generation: 'gen-2' }) + lane = translator() + }, + finishLoad: () => apply(lane.finishLoad(2000)) + } +} diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts new file mode 100644 index 000000000000..ab316b6504db --- /dev/null +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -0,0 +1,269 @@ +import { applyPatch } from 'diff' +import { afterEach, describe, expect, it } from 'vitest' +import { contextTokensFromUsage } from '../../shared/agent-session-context-usage' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + providerItemId +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { AcpTimelineTranslator, acpTurnEnd } from './acp-timeline-translator' + +afterEach(closeProviderTimelineRigs) + +async function genericRig() { + const rig = await openProviderTimelineRig() + const translator = new AcpTimelineTranslator({ + sessionId: 'provider-1', + journalItems: () => rig.journal.snapshot().items + }) + const apply = (events: ProviderTimelineEvent[]) => { + for (const event of events) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + const update = (update: unknown) => + apply(translator.notification('session/update', { sessionId: 'provider-1', update }, 1100)) + return { rig, translator, apply, update } +} + +describe('generic ACP translation', () => { + it('keeps named text across non-text barriers, splits anonymous text, and assembles reasoning', async () => { + const { rig, translator, apply, update } = await genericRig() + apply(translator.openPrompt('send-1', 1000)) + update({ + sessionUpdate: 'agent_message_chunk', + messageId: 'm1', + content: { type: 'text', text: 'First ' } + }) + update({ + sessionUpdate: 'plan', + entries: [{ content: 'Read', status: 'pending', priority: 'medium' }] + }) + update({ + sessionUpdate: 'agent_message_chunk', + messageId: 'm1', + content: { type: 'text', text: 'reply' } + }) + update({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'Thinking' } }) + update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Before' } }) + update({ + sessionUpdate: 'plan', + entries: [{ content: 'Read', status: 'completed', priority: 'medium' }] + }) + update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'After' } }) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) + const rows = await rig.rows() + expect( + rows.flatMap((row) => (row.body.kind === 'message' ? [messageText(row.body)] : [])) + ).toEqual(['First reply', 'Thinking', 'Before', 'After']) + expect(rows.find((row) => messageText(row.body) === 'Thinking')?.body).toMatchObject({ + role: 'reasoning' + }) + expect( + rows.flatMap((row) => + row.body.kind === 'status' && row.body.presentation === 'plan-document' + ? [row.body.text] + : [] + ) + ).toEqual(['- [x] Read']) + }) + + it('merges tool snapshots, bounds input/output and emits a separately keyed valid diff', async () => { + const { rig, translator, apply, update } = await genericRig() + apply(translator.openPrompt('send-1', 1000)) + const oldText = 'same\nbefore\n' + const newText = 'same\nafter\n' + update({ + sessionUpdate: 'tool_call', + toolCallId: 'tool-1', + title: 'Edit', + name: 'edit_file', + rawInput: { text: 'x'.repeat(40000) }, + status: 'pending' + }) + update({ + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + status: 'in_progress', + content: [{ type: 'content', content: { type: 'text', text: 'progress' } }] + }) + update({ + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + status: 'completed', + content: [ + { type: 'content', content: { type: 'text', text: 'y'.repeat(40000) } }, + { type: 'diff', path: 'file.ts', oldText, newText } + ] + }) + // A settled journal row refuses a late provider update that says it is running again. + update({ sessionUpdate: 'tool_call_update', toolCallId: 'tool-1', status: 'in_progress' }) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) + const body = (await rig.row(providerItemId('item', 'tool:tool-1')))?.body + expect(body).toMatchObject({ + name: 'edit_file', + state: 'completed', + input: { truncated: true }, + output: { truncated: true, byteLength: 40000 } + }) + const diff = (await rig.rows()).find((row) => row.body.kind === 'diff')?.body + if (diff?.kind !== 'diff') { + throw new Error('Missing diff') + } + expect(diff.path).toBe('file.ts') + expect(applyPatch(oldText, diff.patch.head)).toBe(newText) + }) + + it('records context window facts and does not double-count cached tokens in prompt usage', async () => { + const { rig, translator, apply, update } = await genericRig() + apply(translator.openPrompt('send-1', 1000)) + update({ sessionUpdate: 'usage_update', used: 42, size: 100 }) + apply( + translator.promptResult( + 'send-1', + { + stopReason: 'end_turn', + usage: { + inputTokens: 50, + outputTokens: 5, + totalTokens: 55, + cachedReadTokens: 20, + cachedWriteTokens: 10 + } + }, + 1200 + ) + ) + const turn = (await rig.turns())[0]! + expect(turn.contextUsage?.window).toEqual({ tokens: 100, capturedAt: 1100 }) + expect(turn.contextUsage?.used).toMatchObject({ + kind: 'estimate', + usage: { + inputTokens: 20, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 10, + outputTokens: 5 + } + }) + if (turn.contextUsage?.used?.kind !== 'estimate') { + throw new Error('Missing usage') + } + expect(contextTokensFromUsage(turn.contextUsage.used.usage)).toBe(50) + }) + + it.each(['refusal', 'max_tokens', 'max_turn_requests'] as const)( + 'records %s as provider failure with the original stop reason', + async (stopReason) => { + const { rig, translator, apply } = await genericRig() + apply(translator.openPrompt('send-1', 1000)) + apply(translator.promptResult('send-1', { stopReason }, 1200)) + expect((await rig.turns())[0]).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect( + (await rig.rows()).some( + (row) => + row.body.kind === 'status' && row.body.providerFrame?.kind === `prompt:${stopReason}` + ) + ).toBe(true) + } + ) + + it('keeps unknown verdicts unknown and preserves unknown frames without opening a turn', async () => { + const { rig, translator, apply } = await genericRig() + expect(acpTurnEnd('turn', 'future_stop', 1200)).not.toHaveProperty('outcome') + apply( + translator.sessionEvent( + { + kind: 'unrecognized', + sessionId: 'provider-1', + raw: { + sessionId: 'provider-1', + update: { sessionUpdate: 'future_event', secret: 'z'.repeat(40000) } + } + }, + 1100 + ) + ) + const rows = await rig.rows() + expect(rows).toHaveLength(1) + expect(rows[0]!.body).toMatchObject({ + kind: 'status', + providerFrame: { payload: { truncated: true } } + }) + expect(rows[0]!.turnScope).toEqual({ kind: 'thread' }) + expect(await rig.turns()).toEqual([]) + }) + + it('ignores live user echoes and options metadata, and rejects cross-session requests', async () => { + const { rig, translator, update } = await genericRig() + update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'echo' } }) + update({ sessionUpdate: 'available_commands_update', availableCommands: [] }) + expect(await rig.rows()).toEqual([]) + expect( + translator.notification( + 'session/update', + { + sessionId: 'other', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'other' } } + }, + 1100 + ) + ).toEqual([]) + expect(() => + translator.request('session/request_permission', { sessionId: 'other' }, 0) + ).toThrow('unknown session') + expect(translator.request('future/request', { sessionId: 'provider-1' }, 0)).toMatchObject({ + events: [{ type: 'provider.frame' }] + }) + }) + + it('adopts generic unmarked load history and never invents a success verdict', async () => { + const { rig, translator, apply, update } = await genericRig() + translator.beginLoad() + update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'User' } }) + update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'History' } }) + apply(translator.finishLoad(1200)) + expect((await rig.turns())[0]).toMatchObject({ state: 'completed' }) + expect((await rig.turns())[0]).not.toHaveProperty('outcome') + expect( + (await rig.rows()).flatMap((row) => (row.body.kind === 'message' ? [row.body.role] : [])) + ).toEqual(['user', 'assistant']) + }) + + it('requires an explicit journal decision for marked replay and uses typed standard events', async () => { + const { rig, translator, apply } = await genericRig() + expect(() => + translator.notification( + 'session/update', + { + sessionId: 'provider-1', + _meta: { isReplay: true }, + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'History' } + } + }, + 1100 + ) + ).toThrow('beginLoad') + apply(translator.openPrompt('send-1', 1000)) + apply( + translator.sessionEvent( + { + kind: 'known', + notification: { + sessionId: 'provider-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'typed' } + } + } + }, + 1100 + ) + ) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) + expect((await rig.rows()).some((row) => messageText(row.body) === 'typed')).toBe(true) + }) +}) diff --git a/src/main/acp/acp-timeline-recordings.test.ts b/src/main/acp/acp-timeline-recordings.test.ts new file mode 100644 index 000000000000..4dccaac7ee8a --- /dev/null +++ b/src/main/acp/acp-timeline-recordings.test.ts @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + messageText +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' + +afterEach(closeProviderTimelineRigs) + +describe('recorded ACP traffic through the journal', () => { + it('assembles basic chunks and partial tool calls with stable identities and usage', async () => { + const fixture = await openAcpFixtureRig() + const rows = await fixture.feed(await readAcpFixture('s1-basic')) + const tools = rows.filter((row) => row.body.kind === 'tool-call') + expect(tools).toHaveLength(2) + expect(tools.map((row) => row.body)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'list_dir', + callId: 'call-1', + state: 'completed', + output: expect.objectContaining({ truncated: false }) + }), + expect.objectContaining({ name: 'read_file', callId: 'call-2', state: 'completed' }) + ]) + ) + expect(rows.filter((row) => row.body.kind === 'message')).toHaveLength(1) + expect((await fixture.rig.turns())[0]).toMatchObject({ + state: 'completed', + outcome: 'success', + contextUsage: { used: { kind: 'estimate' } } + }) + expect(tools.every((tool) => tool.turnScope?.kind === 'turn')).toBe(true) + }) + + it('shows permission options verbatim and keeps allow and reject answers after turn settlement', async () => { + const fixture = await openAcpFixtureRig() + const rows = await fixture.feed(await readAcpFixture('s2-permission')) + const approvals = rows.flatMap((row) => (row.body.kind === 'approval' ? [row.body] : [])) + expect(approvals).toHaveLength(2) + expect(approvals[0]!.options.map((option) => option.id)).toEqual([ + 'allow-edits-session', + 'allow-once', + 'reject-once' + ]) + expect(approvals.map((body) => body.resolution)).toMatchObject([ + { state: 'resolved', selectedOptionId: 'allow-once' }, + { state: 'resolved', selectedOptionId: 'reject-once' } + ]) + expect((await fixture.rig.turns()).map((turn) => turn.outcome)).toEqual([ + 'success', + 'cancellation' + ]) + expect(rows.flatMap((row) => (row.body.kind === 'tool-call' ? [row.body.state] : []))).toEqual([ + 'completed', + 'failed' + ]) + }) + + it('settles the abandoned in_progress tool on cancel and accepts the next prompt', async () => { + const fixture = await openAcpFixtureRig() + const rows = await fixture.feed(await readAcpFixture('s3-cancel')) + expect(rows.flatMap((row) => (row.body.kind === 'tool-call' ? [row.body.state] : []))).toEqual([ + 'failed' + ]) + expect((await fixture.rig.turns()).map((turn) => [turn.state, turn.outcome])).toEqual([ + ['interrupted', 'cancellation'], + ['completed', 'success'] + ]) + expect(rows.some((row) => messageText(row.body)?.includes('ok'))).toBe(true) + }) + + it('suppresses load replay from the existing journal after an assembler restart', async () => { + const fixture = await openAcpFixtureRig() + const frames = await readAcpFixture('s4-resume') + await fixture.feed(frames.filter((frame) => frame.process === 'A')) + const before = await fixture.rig.rows() + fixture.restart() + const loading = frames.filter((frame) => frame.process === 'B') + const nextPrompt = loading.findIndex((frame) => frame.message.method === 'session/prompt') + await fixture.feed(loading.slice(0, nextPrompt)) + fixture.finishLoad() + expect(await fixture.rig.rows()).toEqual(before) + await fixture.feed(loading.slice(nextPrompt)) + expect(await fixture.rig.turns()).toHaveLength(2) + }) + + it('adopts coalesced load replay into an empty journal, including user and completed tools', async () => { + const fixture = await openAcpFixtureRig() + const frames = (await readAcpFixture('s4-resume')).filter((frame) => frame.process === 'B') + const nextPrompt = frames.findIndex((frame) => frame.message.method === 'session/prompt') + await fixture.feed(frames.slice(0, nextPrompt)) + fixture.finishLoad() + const rows = await fixture.rig.rows() + expect(rows.flatMap((row) => (row.body.kind === 'tool-call' ? [row.body] : []))).toHaveLength(2) + expect( + rows.filter((row) => row.body.kind === 'message' && row.body.role === 'user') + ).toHaveLength(1) + expect(rows.some((row) => messageText(row.body)?.includes('Read notes.txt'))).toBe(true) + expect((await fixture.rig.turns())[0]).toMatchObject({ state: 'completed', outcome: 'success' }) + expect(rows.every((row) => row.body.kind === 'turn' || row.turnScope?.kind === 'turn')).toBe( + true + ) + await fixture.feed(frames.slice(nextPrompt)) + expect(await fixture.rig.turns()).toHaveLength(2) + }) + + it('answers questions and plan approval with the recorded exact reply shapes', async () => { + const fixture = await openAcpFixtureRig() + const rows = await fixture.feed(await readAcpFixture('s5-plan-approved')) + expect(rows.flatMap((row) => (row.body.kind === 'question' ? [row.body] : []))).toMatchObject([ + { resolution: { state: 'resolved', answers: [{ questionId: 'q1', optionIds: ['o1'] }] } } + ]) + expect(rows.flatMap((row) => (row.body.kind === 'approval' ? [row.body] : []))).toMatchObject([ + { + subject: { kind: 'plan', text: '# Plan\n\nUpdate the example and run its tests.' }, + resolution: { state: 'resolved', selectedOptionId: 'approved' } + } + ]) + expect((await fixture.rig.turns())[0]!.outcome).toBe('success') + }) + + it('opens and ends the background completion turn without a client prompt', async () => { + const fixture = await openAcpFixtureRig() + const rows = await fixture.feed(await readAcpFixture('s6-background')) + expect((await fixture.rig.turns()).map((turn) => [turn.state, turn.outcome])).toEqual([ + ['completed', 'success'], + ['completed', 'success'] + ]) + const messages = rows.filter((row) => row.body.kind === 'message') + expect(messages).toHaveLength(2) + expect(messageText(messages[1]!.body)).toBe('The background') + expect(messages[0]!.turnScope).not.toEqual(messages[1]!.turnScope) + expect(rows.flatMap((row) => (row.body.kind === 'tool-call' ? [row.body.state] : []))).toEqual([ + 'completed' + ]) + }) +}) diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts new file mode 100644 index 000000000000..1756521d965a --- /dev/null +++ b/src/main/acp/acp-timeline-requests.ts @@ -0,0 +1,39 @@ +import { AcpRpcError } from './acp-errors' +import type { AcpRequestPresentation } from './acp-dialects/acp-dialect' +import { RequestPermissionRequestSchema } from './generated/acp-protocol.generated' + +export const pendingAcpResolution = { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null +} as const + +export function acpPermissionPresentation(params: unknown): AcpRequestPresentation { + const parsed = RequestPermissionRequestSchema.safeParse(params) + if (!parsed.success) { + throw new AcpRpcError(-32602, 'Invalid ACP permission request') + } + const { toolCall, options } = parsed.data + return { + body: { + kind: 'approval', + title: toolCall.title ?? 'Permission requested', + detail: null, + options: options.map((option) => ({ id: option.optionId, label: option.name })), + resolution: pendingAcpResolution + }, + reply: (response) => { + if (response === null) { + return { outcome: { outcome: 'cancelled' } } + } + if ( + response.kind !== 'option' || + !options.some((option) => option.optionId === response.optionId) + ) { + throw new AcpRpcError(-32602, 'Permission answer must select an offered option') + } + return { outcome: { outcome: 'selected', optionId: response.optionId } } + } + } +} diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts new file mode 100644 index 000000000000..04d629e3a0db --- /dev/null +++ b/src/main/acp/acp-timeline-translator.ts @@ -0,0 +1,286 @@ +import { z } from 'zod' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { acpNotificationEnvelopeSchema, acpResponseUsage } from './acp-context-usage' +import { + GENERIC_ACP_DIALECT, + type AcpDialect, + type AcpDialectNotification, + type AcpRequestPresentation +} from './acp-dialects/acp-dialect' +import { AcpRpcError } from './acp-errors' +import type { AcpSessionEvent } from './acp-session-runtime' +import { acpPermissionPresentation } from './acp-timeline-requests' +import { acpSessionUpdate } from './acp-session-update' +import { AcpToolTimeline } from './acp-tool-timeline' +import { SessionNotificationSchema, type PromptResponse } from './generated/acp-protocol.generated' + +const requestSessionSchema = z.object({ sessionId: z.string() }) + +export function acpTurnEnd( + turn: string, + stopReason: string, + at: number, + durationMs?: number +): ProviderTimelineEvent { + return { + type: 'turn.end', + turn, + at, + state: stopReason === 'cancelled' ? 'interrupted' : 'completed', + ...(stopReason === 'end_turn' + ? { outcome: 'success' as const } + : stopReason === 'cancelled' + ? { outcome: 'cancellation' as const } + : ['refusal', 'max_tokens', 'max_turn_requests'].includes(stopReason) + ? { outcome: 'failure' as const } + : {}), + ...(durationMs === undefined ? {} : { durationMs }) + } +} + +type PromptTurn = { turn: string; providerTurn?: string } +type LoadReplay = { adopt: boolean; turn?: string; serial: number } + +export type AcpTimelineTranslatorOptions = { + sessionId: string + /** The host supplies the committed journal after draining earlier writes, before session/load. */ + journalItems(): readonly AgentJournalRenderItem[] + dialect?: AcpDialect +} + +/** Each method consumes one provider frame once; the lane retries returned grammar events. */ +export class AcpTimelineTranslator { + private readonly dialect: AcpDialect + private readonly tools = new AcpToolTimeline() + private readonly aliases = new Map() + private prompt?: PromptTurn + private replay?: LoadReplay + + constructor(private readonly options: AcpTimelineTranslatorOptions) { + this.dialect = options.dialect ?? GENERIC_ACP_DIALECT + } + + openPrompt(clientMessageId: string, at: number): ProviderTimelineEvent[] { + if (this.prompt || this.replay) { + throw new Error('ACP prompt overlaps a prompt or load') + } + const turn = `prompt:${clientMessageId}` + this.prompt = { turn } + return [ + { type: 'input.accepted', clientMessageId, requestedAt: at, join: { turn } }, + { type: 'turn.open', turn, at } + ] + } + + promptResult( + clientMessageId: string, + result: PromptResponse, + at: number + ): ProviderTimelineEvent[] { + const turn = `prompt:${clientMessageId}` + const usage = + this.dialect.promptUsage?.(result, at) ?? + (result.usage ? acpResponseUsage(result.usage, at) : undefined) + const events: ProviderTimelineEvent[] = usage + ? [{ type: 'context.usage', usage, join: { turn } }] + : [] + if (!['end_turn', 'cancelled'].includes(result.stopReason)) { + events.push({ + type: 'provider.frame', + frameKind: `prompt:${result.stopReason}`, + payload: result, + join: { turn } + }) + } + events.push(acpTurnEnd(turn, result.stopReason, at)) + this.tools.end(turn) + if (this.prompt?.turn === turn) { + this.prompt = undefined + } + return events + } + + beginLoad(): void { + if (this.prompt || this.replay) { + throw new Error('ACP load overlaps a prompt or load') + } + this.replay = { adopt: this.options.journalItems().length === 0, serial: 0 } + } + + finishLoad(at: number): ProviderTimelineEvent[] { + const turn = this.replay?.turn + this.replay = undefined + if (!turn) { + return [] + } + this.tools.end(turn) + // Generic ACP history has no stop verdict; load completion must not invent success. + return [{ type: 'turn.end', turn, at, state: 'completed' }] + } + + sessionEvent(event: AcpSessionEvent, at: number): ProviderTimelineEvent[] { + return this.notification( + 'session/update', + event.kind === 'known' ? event.notification : event.raw, + at + ) + } + + notification(method: string, params: unknown, at: number): ProviderTimelineEvent[] { + const envelope = acpNotificationEnvelopeSchema.safeParse(params) + const session = requestSessionSchema.safeParse(params) + if (session.success && session.data.sessionId !== this.options.sessionId) { + return [] + } + const extension = this.dialect.notification?.(method, params, at) + const markedReplay = + (envelope.success && envelope.data._meta?.isReplay === true) || extension?.replay === true + if (markedReplay && !this.replay) { + throw new Error('ACP replay requires beginLoad journal decision') + } + const isReplay = extension?.replay ?? (markedReplay || this.replay !== undefined) + if (isReplay && this.replay && !this.replay.adopt) { + return [] + } + const standard = + method === 'session/update' ? SessionNotificationSchema.safeParse(params) : undefined + const events: ProviderTimelineEvent[] = [] + const substantive = + standard?.success && + [ + 'user_message_chunk', + 'agent_message_chunk', + 'agent_thought_chunk', + 'tool_call', + 'tool_call_update', + 'plan' + ].includes(standard.data.update.sessionUpdate) + if ( + isReplay && + this.replay?.adopt && + this.replay.turn && + standard?.success && + standard.data.update.sessionUpdate === 'user_message_chunk' + ) { + events.push({ type: 'turn.end', turn: this.replay.turn, at, state: 'completed' }) + this.tools.end(this.replay.turn) + this.replay.turn = undefined + } + const turn = this.routeTurn( + extension, + events, + at, + substantive || extension?.end !== undefined, + isReplay + ) + const join = turn === undefined ? {} : { join: { turn } } + if (extension?.usage) { + events.push({ type: 'context.usage', usage: extension.usage, ...join }) + } + if (extension?.end && turn) { + // Client prompts settle on their own response; extension completions own autonomous turns. + if (this.prompt?.turn !== turn) { + events.push(acpTurnEnd(turn, extension.end.stopReason, at, extension.end.durationMs)) + this.tools.end(turn) + if (this.replay?.turn === turn) { + this.replay.turn = undefined + } + } + return events + } + if (standard?.success) { + return [ + ...events, + ...acpSessionUpdate( + standard.data, + turn, + at, + isReplay && (this.replay?.adopt ?? false), + this.tools, + this.dialect + ) + ] + } + events.push({ type: 'provider.frame', frameKind: method, payload: params, ...join }) + return events + } + + request( + method: string, + params: unknown, + id: string | number + ): { + events: ProviderTimelineEvent[] + presentation?: AcpRequestPresentation + } { + const session = requestSessionSchema.safeParse(params) + if (!session.success || session.data.sessionId !== this.options.sessionId) { + throw new AcpRpcError(-32602, 'ACP request belongs to an unknown session') + } + const presentation = + method === 'session/request_permission' + ? acpPermissionPresentation(params) + : this.dialect.request?.(method, params) + if (!presentation) { + return { + events: [{ type: 'provider.frame', frameKind: `request:${method}`, payload: params }] + } + } + return { + presentation, + events: [ + { + type: 'request.open', + request: `${method}:${JSON.stringify(id)}`, + body: presentation.body + } + ] + } + } + + private alias(providerTurn: string, turn: string): void { + if (this.aliases.size >= 128) { + this.aliases.delete(this.aliases.keys().next().value ?? '') + } + this.aliases.set(providerTurn, turn) + } + + private routeTurn( + extension: AcpDialectNotification | undefined, + events: ProviderTimelineEvent[], + at: number, + substantive: boolean, + isReplay: boolean + ): string | undefined { + const providerTurn = extension?.turn + const known = providerTurn ? this.aliases.get(providerTurn) : undefined + if (known) { + return known + } + if (isReplay && this.replay?.adopt && substantive) { + if (!this.replay.turn) { + this.replay.turn = providerTurn ?? `replay:${this.replay.serial++}` + events.push({ type: 'turn.open', turn: this.replay.turn, at: extension?.at ?? at }) + } + if (providerTurn) { + this.alias(providerTurn, this.replay.turn) + } + return this.replay.turn + } + if (extension?.agentInitiated && providerTurn && substantive) { + this.alias(providerTurn, providerTurn) + events.push({ type: 'turn.open', turn: providerTurn, at: extension.at ?? at }) + return providerTurn + } + if (providerTurn && this.prompt && !this.prompt.providerTurn) { + this.prompt.providerTurn = providerTurn + this.alias(providerTurn, this.prompt.turn) + } + return ( + (providerTurn ? (this.aliases.get(providerTurn) ?? providerTurn) : undefined) ?? + this.replay?.turn ?? + this.prompt?.turn + ) + } +} diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts new file mode 100644 index 000000000000..84f643a2ec63 --- /dev/null +++ b/src/main/acp/acp-tool-timeline.ts @@ -0,0 +1,170 @@ +import { createPatch } from 'diff' +import { z } from 'zod' +import type { AgentJournalToolCallItem } from '../../shared/agent-session-journal-types' +import { + boundPayload, + boundToolInput, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import type { ToolCallUpdate } from './generated/acp-protocol.generated' + +const rawStreamsSchema = z.object({ stdout: z.string().optional(), stderr: z.string().optional() }) + +function outputText(update: ToolCallUpdate): string | undefined { + const text = update.content?.flatMap((block) => + block.type === 'content' && block.content.type === 'text' ? [block.content.text] : [] + ) + if (text?.length) { + return text.join('\n') + } + if (update.rawOutput === undefined) { + return undefined + } + if (typeof update.rawOutput === 'string') { + return update.rawOutput + } + const streams = rawStreamsSchema.safeParse(update.rawOutput) + if (streams.success && (streams.data.stdout !== undefined || streams.data.stderr !== undefined)) { + return [streams.data.stdout, streams.data.stderr] + .filter((value) => value !== undefined) + .join('\n') + } + return JSON.stringify(update.rawOutput) ?? '' +} + +/** A whole-file replacement is a linear-time unified patch, even for unrelated large files. */ +function replacementPatch(path: string, oldText: string, newText: string): string { + const lines = (text: string, prefix: string): { count: number; text: string } => { + if (!text) { + return { count: 0, text: '' } + } + const entries = text.split('\n') + if (entries.at(-1) === '') { + entries.pop() + } + return { + count: entries.length, + text: + entries.map((line) => `${prefix}${line}\n`).join('') + + (text.endsWith('\n') ? '' : '\\ No newline at end of file\n') + } + } + const oldLines = lines(oldText, '-') + const newLines = lines(newText, '+') + return `--- ${path}\n+++ ${path}\n@@ -${oldLines.count ? 1 : 0},${oldLines.count} +${newLines.count ? 1 : 0},${newLines.count} @@\n${oldLines.text}${newLines.text}` +} + +function boundedDiffPatch(path: string, oldText: string, newText: string): string { + const patch = + oldText.length + newText.length <= 256 * 1024 + ? createPatch(path, oldText, newText, undefined, undefined, { maxEditLength: 1024 }) + : undefined + return patch ?? replacementPatch(path, oldText, newText) +} + +type ToolSnapshot = { body: AgentJournalToolCallItem; turn?: string } + +/** Holds only bounded snapshots needed to fill ACP's partial tool updates. */ +export class AcpToolTimeline { + private readonly tools = new Map() + private bytes = 0 + + translate(update: ToolCallUpdate, dialect: AcpDialect, turn?: string): ProviderTimelineEvent[] { + const previous = this.tools.get(update.toolCallId) + const output = outputText(update) + const state = + update.status === 'completed' + ? 'completed' + : update.status === 'failed' + ? 'failed' + : update.status === 'pending' || update.status === 'in_progress' + ? 'running' + : (previous?.body.state ?? 'running') + const body: AgentJournalToolCallItem = { + kind: 'tool-call', + callId: update.toolCallId, + name: + dialect.toolName?.(update) ?? + update.name ?? + previous?.body.name ?? + update.title ?? + update.kind ?? + 'tool', + input: + update.rawInput === undefined + ? (previous?.body.input ?? null) + : boundToolInput(update.rawInput, DEFAULT_JOURNAL_PAYLOAD_LIMITS), + state, + ...(output === undefined + ? previous?.body.output + ? { output: previous.body.output } + : {} + : { output: boundPayload(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) }) + } + const joinedTurn = previous?.turn ?? turn + const snapshot: ToolSnapshot = { + body, + ...(joinedTurn === undefined ? {} : { turn: joinedTurn }) + } + this.remember(update.toolCallId, snapshot) + const join = joinedTurn === undefined ? {} : { join: { turn: joinedTurn } } + const events: ProviderTimelineEvent[] = [ + { + type: state === 'running' ? (previous ? 'item.update' : 'item.open') : 'item.close', + item: `tool:${update.toolCallId}`, + body, + ...join + } + ] + for (const content of update.content ?? []) { + if (content.type === 'diff') { + events.push({ + type: 'item.update', + item: `diff:${JSON.stringify([update.toolCallId, content.path])}`, + body: { + kind: 'diff', + path: content.path, + patch: boundPayload( + boundedDiffPatch(content.path, content.oldText ?? '', content.newText), + DEFAULT_JOURNAL_PAYLOAD_LIMITS + ) + }, + ...join + }) + } else if (content.type === 'terminal' || content.content.type !== 'text') { + events.push({ + type: 'provider.frame', + frameKind: `tool-content:${content.type}`, + payload: content, + ...join + }) + } + } + return events + } + + end(turn: string): void { + for (const [key, snapshot] of this.tools) { + if (snapshot.turn === turn) { + this.bytes -= this.size(key, snapshot) + this.tools.delete(key) + } + } + } + + private size(key: string, snapshot: ToolSnapshot): number { + return Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(snapshot)) + } + + private remember(key: string, snapshot: ToolSnapshot): void { + const previous = this.tools.get(key) + const bytes = this.bytes - (previous ? this.size(key, previous) : 0) + this.size(key, snapshot) + if ((!previous && this.tools.size >= 128) || bytes > 1024 * 1024) { + throw new Error('ACP tool snapshot budget exceeded') + } + this.tools.set(key, snapshot) + this.bytes = bytes + } +} diff --git a/src/main/acp/fixtures/s1-basic.jsonl b/src/main/acp/fixtures/s1-basic.jsonl new file mode 100644 index 000000000000..e15324dcc61a --- /dev/null +++ b/src/main/acp/fixtures/s1-basic.jsonl @@ -0,0 +1,11 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" list"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"list_dir","rawInput":{"target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"other","title":"List `/workspace/file-1`","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"read_file","rawInput":{"target_file":"/workspace/file-2"},"_meta":{"x.ai/tool":{"name":"read_file","kind":"read"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"read","title":"Read `/workspace/file-2`","locations":[{"path":"/workspace/file-2"}],"rawInput":{"variant":"ReadFile","target_file":"/workspace/file-2"},"_meta":{"x.ai/tool":{"name":"read_file","kind":"read"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","content":[{"type":"content","content":{"type":"text","text":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n"}}],"rawOutput":{"type":"ReadFile","FileContent":{"content":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","content_concise":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","absolute_path":"/workspace/file-2","offset":null,"raw_output":"# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","total_lines":4}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-3\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":68922,"outputTokens":195,"cachedReadTokens":35840,"cacheCreationTokens":0},"elapsed_ms":5289}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":68922,"outputTokens":195,"cachedReadTokens":35840,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s2-permission.jsonl b/src/main/acp/fixtures/s2-permission.jsonl new file mode 100644 index 000000000000..fe5198b52458 --- /dev/null +++ b/src/main/acp/fixtures/s2-permission.jsonl @@ -0,0 +1,20 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" create"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"write","rawInput":{"file_path":"/workspace/file-1","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"edit","title":"Write `/workspace/file-1`","content":[{"type":"diff","path":"/workspace/file-1","oldText":"","newText":"hello from grok\n"}],"locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"Write","file_path":"/workspace/file-1","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call-1","kind":"edit","title":"Write `/workspace/file-1` \u2014 hook 'plugin/capture-force-ask/hooks:pre_tool_use[0].hooks[0]' asks: capture harness forces a permission prompt","rawInput":{"variant":"Write","file_path":"/workspace/file-1","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"options":[{"optionId":"allow-edits-session","name":"Yes, allow all edits during this session","kind":"allow_always"},{"optionId":"allow-once","name":"Yes","kind":"allow_once"},{"optionId":"reject-once","name":"No, and tell Grok what to do differently","kind":"reject_once"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"diff","path":"/workspace/file-1","oldText":"","newText":"hello from grok\n"}],"rawOutput":{"type":"SearchReplace","EditsApplied":{"old_string":"","new_string":"hello from grok\n","tool_output_for_prompt":"The file /workspace/file-1 has been created.","tool_output_for_prompt_concise":"The file /workspace/file-1 has been created.","absolute_path":"/workspace/file-1","edits":{"details":[{"old_string":"","old_line":1,"new_string":"hello from grok\n","new_line":1,"context_before":"","context_after":"","line_prefix":""}]}}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":69120,"outputTokens":260,"cachedReadTokens":36096,"cacheCreationTokens":0},"elapsed_ms":8673}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":69120,"outputTokens":260,"cachedReadTokens":36096,"cacheCreationTokens":0}}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":5,"method":"session/prompt","params":{"sessionId":"session-2","prompt":[{"type":"text","text":"Fixture prompt"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" create"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"write","rawInput":{"file_path":"/workspace/file-2","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"edit","title":"Write `/workspace/file-2`","content":[{"type":"diff","path":"/workspace/file-2","oldText":"","newText":"hello from grok\n"}],"locations":[{"path":"/workspace/file-2"}],"rawInput":{"variant":"Write","file_path":"/workspace/file-2","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"session-2","toolCall":{"toolCallId":"call-2","kind":"edit","title":"Write `/workspace/file-2` \u2014 hook 'plugin/capture-force-ask/hooks:pre_tool_use[0].hooks[0]' asks: capture harness forces a permission prompt","rawInput":{"variant":"Write","file_path":"/workspace/file-2","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"options":[{"optionId":"allow-edits-session","name":"Yes, allow all edits during this session","kind":"allow_always"},{"optionId":"allow-once","name":"Yes","kind":"allow_once"},{"optionId":"reject-once","name":"No, and tell Grok what to do differently","kind":"reject_once"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":1,"result":{"outcome":{"outcome":"selected","optionId":"reject-once"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"failed","content":[{"type":"content","content":{"type":"text","text":"User rejected the execution for tool `write`"}}]},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-2","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-2","stop_reason":"cancelled","usage":{"inputTokens":34448,"outputTokens":163,"cachedReadTokens":1664,"cacheCreationTokens":0},"elapsed_ms":4475}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":5,"result":{"stopReason":"cancelled","_meta":{"usage":{"inputTokens":34448,"outputTokens":163,"cachedReadTokens":1664,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s3-cancel.jsonl b/src/main/acp/fixtures/s3-cancel.jsonl new file mode 100644 index 000000000000..a8828e5ef760 --- /dev/null +++ b/src/main/acp/fixtures/s3-cancel.jsonl @@ -0,0 +1,13 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" run"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"run_terminal_command","rawInput":{"command":"sleep 20; echo finished > cancel-marker.txt","description":"Sleep 20s then write cancel marker","block_until_ms":35000},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"execute","title":"Execute `sleep 20; echo finished > cancel-marker.txt`","content":[{"type":"content","content":{"type":"text","text":"Sleep 20s then write cancel marker"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 20; echo finished > cancel-marker.txt","description":"Sleep 20s then write cancel marker","is_background":false,"block_until_ms":35000},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":""}}],"rawOutput":{"type":"Bash","output":[],"output_for_prompt":"","exit_code":0,"command":"sleep 20; echo finished > cancel-marker.txt","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/workspace/file-1","output_file":"","total_bytes":0}}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"cancelled","usage":{"inputTokens":34344,"outputTokens":318,"cachedReadTokens":1664,"cacheCreationTokens":0},"elapsed_ms":7773}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled","_meta":{"usage":{"inputTokens":34344,"outputTokens":318,"cachedReadTokens":1664,"cacheCreationTokens":0}}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ok"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-2","stop_reason":"end_turn","usage":{"inputTokens":34876,"outputTokens":189,"cachedReadTokens":34304,"cacheCreationTokens":0},"elapsed_ms":3373}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":34876,"outputTokens":189,"cachedReadTokens":34304,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s4-resume.jsonl b/src/main/acp/fixtures/s4-resume.jsonl new file mode 100644 index 000000000000..479e13236172 --- /dev/null +++ b/src/main/acp/fixtures/s4-resume.jsonl @@ -0,0 +1,23 @@ +{"direction":"out","process":"A","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" read"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"grep","rawInput":{"pattern":".","glob":"notes.txt"},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"search","title":".","locations":[],"rawInput":{"variant":"Grep","pattern":".","path":null,"glob":"notes.txt","-i":false,"type":null,"multiline":false},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"list_dir","rawInput":{"target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"other","title":"List `/workspace/file-1`","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-2\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"found 3 matches"}}],"rawOutput":{"type":"GrepSearch","stdout":[60,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,32,119,111,114,107,115,112,97,99,101,95,112,97,116,104,61,34,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,34,62,10,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,47,110,111,116,101,115,46,116,120,116,58,101,120,97,109,112,108,101,10,60,47,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,62],"stderr":[],"exit_code":0,"match_count":3,"file_matches":[{"path":"/workspace/file-3","matches":[{"line_number":1,"content":"apples"},{"line_number":2,"content":"bananas"},{"line_number":3,"content":"cherries"}]}]}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":68864,"outputTokens":121,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5043}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":68864,"outputTokens":121,"cachedReadTokens":35968,"cacheCreationTokens":0}}}}} +{"direction":"out","process":"B","message":{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":"session-1"}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"Read notes.txt and tell me its second line."},"_meta":{"promptIndex":0}},"_meta":{"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll read `notes.txt` and report the second line."}},"_meta":{"promptId":"turn-1","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"List `/workspace/file-1`","status":"completed","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-2\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":".","kind":"search","status":"completed","content":[{"type":"content","content":{"type":"text","text":"found 3 matches"}}],"rawInput":{"variant":"Grep","pattern":".","path":null,"glob":"notes.txt","-i":false,"type":null,"multiline":false},"rawOutput":{"type":"GrepSearch","stdout":[60,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,32,119,111,114,107,115,112,97,99,101,95,112,97,116,104,61,34,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,34,62,10,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,47,110,111,116,101,115,46,116,120,116,58,101,120,97,109,112,108,101,10,60,47,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,62],"stderr":[],"exit_code":0,"match_count":3,"file_matches":[{"path":"/workspace/file-3","matches":[{"line_number":1,"content":"apples"},{"line_number":2,"content":"bananas"},{"line_number":3,"content":"cherries"}]}]},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The second line of `notes.txt` is `bananas`."}},"_meta":{"promptId":"turn-1","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"_x.ai/session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":68864,"outputTokens":121,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5043},"_meta":{"isReplay":true}}}} +{"direction":"out","process":"B","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" second"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-2","stop_reason":"end_turn","usage":{"inputTokens":34621,"outputTokens":36,"cachedReadTokens":34432,"cacheCreationTokens":0},"elapsed_ms":1829}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":34621,"outputTokens":36,"cachedReadTokens":34432,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s5-plan-approved.jsonl b/src/main/acp/fixtures/s5-plan-approved.jsonl new file mode 100644 index 000000000000..43b2e30a194e --- /dev/null +++ b/src/main/acp/fixtures/s5-plan-approved.jsonl @@ -0,0 +1,15 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" look"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"ask_user_question","rawInput":{"questions":[{"question":"Which test approach do you want for the subtract function?","options":[{"label":"node:test (Recommended)","description":"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."},{"label":"Plain assert script","description":"A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework."},{"label":"Add a package.json and a test script","description":"Add package.json with a test script that runs the same Node built-in tests, so npm test works."}]}]},"_meta":{"x.ai/tool":{"name":"ask_user_question","kind":"ask_user"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"other","title":"Ask: Which test approach do you want for the subtract function?","locations":[],"rawInput":{"variant":"AskUserQuestion","questions":[{"question":"Which test approach do you want for the subtract function?","options":[{"label":"node:test (Recommended)","description":"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."},{"label":"Plain assert script","description":"A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework."},{"label":"Add a package.json and a test script","description":"Add package.json with a test script that runs the same Node built-in tests, so npm test works."}],"multiSelect":null}]},"_meta":{"x.ai/tool":{"name":"ask_user_question","kind":"ask_user"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"_x.ai/ask_user_question","params":{"sessionId":"session-1","toolCallId":"call-1","questions":[{"question":"Which test approach do you want for the subtract function?","options":[{"label":"node:test (Recommended)","description":"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."},{"label":"Plain assert script","description":"A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework."},{"label":"Add a package.json and a test script","description":"Add package.json with a test script that runs the same Node built-in tests, so npm test works."}],"multiSelect":null}],"mode":"default"}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":"accepted","answers":{"Which test approach do you want for the subtract function?":"node:test (Recommended)"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"User has answered your questions: \"Which test approach do you want for the subtract function?\"=\"node:test (Recommended)\". You can now continue with the user's answers in mind."}}],"rawOutput":{"type":"AskUserQuestion","UserAnswered":{"message":"User has answered your questions: \"Which test approach do you want for the subtract function?\"=\"node:test (Recommended)\". You can now continue with the user's answers in mind."}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"exit_plan_mode","rawInput":{},"_meta":{"x.ai/tool":{"name":"exit_plan_mode","kind":"exit_plan"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"other","title":"Plan: Exit","locations":[],"rawInput":{"variant":"ExitPlanMode"},"_meta":{"x.ai/tool":{"name":"exit_plan_mode","kind":"exit_plan"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":1,"method":"_x.ai/exit_plan_mode","params":{"sessionId":"session-1","toolCallId":"call-2","planContent":"# Plan\n\nUpdate the example and run its tests."}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":1,"result":{"outcome":"approved"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","title":"Plan mode exited","content":[{"type":"content","content":{"type":"text","text":"Plan file: /workspace/file-1"}}],"rawOutput":{"type":"ExitPlanMode","PlanReady":{"message":"Your plan has been approved. You can now start coding.","plan_content":"# Add subtract and a node:test\n\n## Problem\n\n`math.js` only exports `add(a, b)`. There is no `subtract`, and nothing in this folder can check that the math functions return the right values.\n\n## User-facing change\n\nCallers can import `subtract` from `math.js` and get `a - b`. A new test file checks that behavior and can be run with Node's built-in test runner.\n\n## Approach\n\nStay inside this folder. No new dependencies and no `package.json`. You chose `node:test`.\n\n1. In `math.js`, add `subtract(a, b)` next to `add`, using the same style: a named export that returns `a - b`.\n2. Add `math.test.js` that imports `subtract` and uses `node:test` plus `node:assert/strict`. Cover a positive difference, a negative difference, and zero.\n3. Run `node --test math.test.js` from this folder and confirm the tests pass.\n\n## Out of scope\n\n- Changing `add`\n- Editing `README.md` or `notes.txt`\n- Adding a package manager, test script, or extra files\n","plan_file_path":"/workspace/file-1"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":331949,"outputTokens":3858,"cachedReadTokens":294144,"cacheCreationTokens":0},"elapsed_ms":64864}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":331949,"outputTokens":3858,"cachedReadTokens":294144,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s6-background.jsonl b/src/main/acp/fixtures/s6-background.jsonl new file mode 100644 index 000000000000..e63527fd9d23 --- /dev/null +++ b/src/main/acp/fixtures/s6-background.jsonl @@ -0,0 +1,11 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" start"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"run_terminal_command","rawInput":{"command":"sleep 15; echo bg-done > bg-marker.txt","description":"Sleep then write bg-marker.txt","block_until_ms":0},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"execute","title":"Execute `sleep 15; echo bg-done > bg-marker.txt`","content":[{"type":"content","content":{"type":"text","text":"Sleep then write bg-marker.txt"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 15; echo bg-done > bg-marker.txt","description":"Sleep then write bg-marker.txt","is_background":false,"block_until_ms":0},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","title":"[bg] sleep 15; echo bg-done > bg-marker.txt (01a10366)","content":[{"type":"content","content":{"type":"text","text":"Background task 01a10366-1e7f-7191-bcd1-fac24585851f started"}}],"rawOutput":{"type":"BackgroundTaskStarted","task_id":"01a10366-1e7f-7191-bcd1-fac24585851f","task_type":"bash","output_file":"/workspace/file-1","status":"running","command":"sleep 15; echo bg-done > bg-marker.txt","summary":"Background task 01a10366-1e7f-7191-bcd1-fac24585851f started","retrieval_hint":"Use get_command_or_subagent_output with task_ids=[\"01a10366-1e7f-7191-bcd1-fac24585851f\"] when you need the output."}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":69127,"outputTokens":207,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5774}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":69127,"outputTokens":207,"cachedReadTokens":35968,"cacheCreationTokens":0}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}},"_meta":{"promptId":"task-completed-2","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" background"}},"_meta":{"promptId":"task-completed-2","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"task-completed-2","stop_reason":"end_turn","usage":{"inputTokens":34928,"outputTokens":219,"cachedReadTokens":34688,"cacheCreationTokens":0},"elapsed_ms":4520}}}} From 99138f10844e766bda1e7deb8f75b4c6fb9d9fb3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:46:52 -0700 Subject: [PATCH 049/269] Preserve fixture answer linkage and handle all typed ACP updates --- src/main/acp/acp-session-update.ts | 5 ++++- src/main/acp/acp-timeline-fixture.test-support.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index 516b95d465ad..51040467551c 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -81,7 +81,10 @@ export function acpSessionUpdate( case 'session_info_update': // These feed commands, options and the session title, outside the timeline. return [] - default: + case 'plan_update': + case 'plan_removed': + case 'compaction_update': + case 'compaction_summary_chunk': return [{ type: 'provider.frame', frameKind: update.sessionUpdate, payload: update, ...join }] } } diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts index a019328a9373..ffe89fcc17b5 100644 --- a/src/main/acp/acp-timeline-fixture.test-support.ts +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -140,7 +140,7 @@ export async function openAcpFixtureRig() { resolvedAt: at } }, - { fence: 1 } + { fence: 1, turnScope: row.turnScope ?? { kind: 'thread' } } ) requests.delete(message.id) } else if (direction === 'in' && message.method) { From c484c9bc3adfd2e5f9bc0510df85de6d89f3ab77 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:46:53 -0700 Subject: [PATCH 050/269] Keep replay chunks and long-turn tool snapshots bounded --- src/main/acp/acp-replay-user-messages.ts | 43 ++++++++++ src/main/acp/acp-session-update.ts | 6 +- src/main/acp/acp-timeline-generic.test.ts | 87 ++++++++++++++++++++ src/main/acp/acp-timeline-recordings.test.ts | 1 + src/main/acp/acp-timeline-translator.ts | 47 ++++++++--- src/main/acp/acp-tool-timeline.ts | 20 ++++- 6 files changed, 187 insertions(+), 17 deletions(-) create mode 100644 src/main/acp/acp-replay-user-messages.ts diff --git a/src/main/acp/acp-replay-user-messages.ts b/src/main/acp/acp-replay-user-messages.ts new file mode 100644 index 000000000000..189d2e6aead5 --- /dev/null +++ b/src/main/acp/acp-replay-user-messages.ts @@ -0,0 +1,43 @@ +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import type { SessionUpdate } from './generated/acp-protocol.generated' + +/** User text has no grammar stream channel; adopted history needs a bounded whole-body snapshot. */ +export class AcpReplayUserMessages { + private current?: { id: string | null; text: string } + + observe(update: SessionUpdate): { startsMessage: boolean; body?: AgentJournalMessageItem } { + if (update.sessionUpdate !== 'user_message_chunk') { + if ( + [ + 'agent_message_chunk', + 'agent_thought_chunk', + 'tool_call', + 'tool_call_update', + 'plan' + ].includes(update.sessionUpdate) + ) { + this.current = undefined + } + return { startsMessage: false } + } + const id = update.messageId ?? null + const startsMessage = !this.current || this.current.id !== id + const previous = startsMessage ? '' : this.current!.text + if (update.content.type !== 'text') { + return { startsMessage } + } + const text = boundInlineText( + previous + update.content.text, + DEFAULT_JOURNAL_PAYLOAD_LIMITS + ).text + this.current = { id, text } + return { + startsMessage, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + } + } +} diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index 51040467551c..d314e72c1297 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -1,3 +1,4 @@ +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { boundInlineText, @@ -14,7 +15,8 @@ export function acpSessionUpdate( at: number, replay: boolean, tools: AcpToolTimeline, - dialect: AcpDialect + dialect: AcpDialect, + replayUserBody?: AgentJournalMessageItem ): ProviderTimelineEvent[] { const update = notification.update const join = turn === undefined ? {} : { join: { turn } } @@ -40,7 +42,7 @@ export function acpSessionUpdate( { type: 'item.update', item: `replay-user:${turn}:${update.messageId ?? ''}`, - body: { + body: replayUserBody ?? { kind: 'message', role: 'user', blocks: [{ type: 'text', text: update.content.text }] diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts index ab316b6504db..56de41ec7762 100644 --- a/src/main/acp/acp-timeline-generic.test.ts +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -266,4 +266,91 @@ describe('generic ACP translation', () => { apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) expect((await rig.rows()).some((row) => messageText(row.body) === 'typed')).toBe(true) }) + it('adopts streamed user history across multiple turns without replacing earlier users', async () => { + const { rig, translator, apply, update } = await genericRig() + translator.beginLoad() + update({ + sessionUpdate: 'user_message_chunk', + messageId: 'user-1', + content: { type: 'text', text: 'First ' } + }) + update({ + sessionUpdate: 'user_message_chunk', + messageId: 'user-1', + content: { type: 'text', text: 'question' } + }) + update({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'First answer' } + }) + update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'Second ' } }) + update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'question' } }) + update({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Second answer' } + }) + apply(translator.finishLoad(1200)) + expect( + (await rig.rows()).flatMap((row) => + row.body.kind === 'message' ? [messageText(row.body)] : [] + ) + ).toEqual(['First question', 'First answer', 'Second question', 'Second answer']) + expect(await rig.turns()).toHaveLength(2) + }) + + it('evicts completed snapshots during long turns while preserving active tool input', async () => { + const { rig, translator, apply, update } = await genericRig() + apply(translator.openPrompt('send-1', 1000)) + update({ + sessionUpdate: 'tool_call', + toolCallId: 'active', + title: 'Active', + rawInput: { important: true } + }) + for (let index = 0; index < 140; index += 1) { + update({ + sessionUpdate: 'tool_call', + toolCallId: `done-${index}`, + title: 'Read', + status: 'pending' + }) + update({ + sessionUpdate: 'tool_call_update', + toolCallId: `done-${index}`, + status: 'completed' + }) + await rig.rows() + } + update({ sessionUpdate: 'tool_call_update', toolCallId: 'active', status: 'failed' }) + apply(translator.promptResult('send-1', { stopReason: 'cancelled' }, 1200)) + expect((await rig.row(providerItemId('item', 'tool:active')))?.body).toMatchObject({ + name: 'Active', + input: { important: true }, + state: 'failed' + }) + expect( + (await rig.rows()).filter( + (row) => row.body.kind === 'tool-call' && row.body.state === 'completed' + ) + ).toHaveLength(140) + }) + + it('keeps a valid replacement patch when a diff exceeds the edit-search budget', async () => { + const { rig, update } = await genericRig() + const oldText = Array.from({ length: 520 }, (_, index) => `old-${index}\n`).join('') + const newText = Array.from({ length: 520 }, (_, index) => `new-${index}\n`).join('') + update({ + sessionUpdate: 'tool_call', + toolCallId: 'edit', + title: 'Edit', + status: 'completed', + content: [{ type: 'diff', path: 'file.ts', oldText, newText }] + }) + const body = (await rig.rows()).find((row) => row.body.kind === 'diff')?.body + if (body?.kind !== 'diff') { + throw new Error('Missing diff') + } + expect(body.patch.truncated).toBe(false) + expect(applyPatch(oldText, body.patch.head)).toBe(newText) + }) }) diff --git a/src/main/acp/acp-timeline-recordings.test.ts b/src/main/acp/acp-timeline-recordings.test.ts index 4dccaac7ee8a..7155e690f69d 100644 --- a/src/main/acp/acp-timeline-recordings.test.ts +++ b/src/main/acp/acp-timeline-recordings.test.ts @@ -28,6 +28,7 @@ describe('recorded ACP traffic through the journal', () => { expect((await fixture.rig.turns())[0]).toMatchObject({ state: 'completed', outcome: 'success', + durationMs: 5289, contextUsage: { used: { kind: 'estimate' } } }) expect(tools.every((tool) => tool.turnScope?.kind === 'turn')).toBe(true) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 04d629e3a0db..74a8c2f19335 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -8,6 +8,7 @@ import { type AcpDialectNotification, type AcpRequestPresentation } from './acp-dialects/acp-dialect' +import { AcpReplayUserMessages } from './acp-replay-user-messages' import { AcpRpcError } from './acp-errors' import type { AcpSessionEvent } from './acp-session-runtime' import { acpPermissionPresentation } from './acp-timeline-requests' @@ -39,8 +40,8 @@ export function acpTurnEnd( } } -type PromptTurn = { turn: string; providerTurn?: string } -type LoadReplay = { adopt: boolean; turn?: string; serial: number } +type PromptTurn = { turn: string; providerTurn?: string; durationMs?: number } +type LoadReplay = { adopt: boolean; turn?: string; serial: number; users: AcpReplayUserMessages } export type AcpTimelineTranslatorOptions = { sessionId: string @@ -93,7 +94,14 @@ export class AcpTimelineTranslator { join: { turn } }) } - events.push(acpTurnEnd(turn, result.stopReason, at)) + events.push( + acpTurnEnd( + turn, + result.stopReason, + at, + this.prompt?.turn === turn ? this.prompt.durationMs : undefined + ) + ) this.tools.end(turn) if (this.prompt?.turn === turn) { this.prompt = undefined @@ -105,7 +113,11 @@ export class AcpTimelineTranslator { if (this.prompt || this.replay) { throw new Error('ACP load overlaps a prompt or load') } - this.replay = { adopt: this.options.journalItems().length === 0, serial: 0 } + this.replay = { + adopt: this.options.journalItems().length === 0, + serial: 0, + users: new AcpReplayUserMessages() + } } finishLoad(at: number): ProviderTimelineEvent[] { @@ -156,13 +168,11 @@ export class AcpTimelineTranslator { 'tool_call_update', 'plan' ].includes(standard.data.update.sessionUpdate) - if ( - isReplay && - this.replay?.adopt && - this.replay.turn && - standard?.success && - standard.data.update.sessionUpdate === 'user_message_chunk' - ) { + const replayUser = + isReplay && this.replay?.adopt && standard?.success + ? this.replay.users.observe(standard.data.update) + : undefined + if (replayUser?.startsMessage && this.replay?.turn) { events.push({ type: 'turn.end', turn: this.replay.turn, at, state: 'completed' }) this.tools.end(this.replay.turn) this.replay.turn = undefined @@ -180,7 +190,17 @@ export class AcpTimelineTranslator { } if (extension?.end && turn) { // Client prompts settle on their own response; extension completions own autonomous turns. - if (this.prompt?.turn !== turn) { + if (this.prompt?.turn === turn) { + this.prompt.durationMs = extension.end.durationMs + } else { + if (!['end_turn', 'cancelled'].includes(extension.end.stopReason)) { + events.push({ + type: 'provider.frame', + frameKind: `turn:${extension.end.stopReason}`, + payload: params, + ...join + }) + } events.push(acpTurnEnd(turn, extension.end.stopReason, at, extension.end.durationMs)) this.tools.end(turn) if (this.replay?.turn === turn) { @@ -198,7 +218,8 @@ export class AcpTimelineTranslator { at, isReplay && (this.replay?.adopt ?? false), this.tools, - this.dialect + this.dialect, + replayUser?.body ) ] } diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts index 84f643a2ec63..71ce27647e42 100644 --- a/src/main/acp/acp-tool-timeline.ts +++ b/src/main/acp/acp-tool-timeline.ts @@ -160,10 +160,26 @@ export class AcpToolTimeline { private remember(key: string, snapshot: ToolSnapshot): void { const previous = this.tools.get(key) - const bytes = this.bytes - (previous ? this.size(key, previous) : 0) + this.size(key, snapshot) - if ((!previous && this.tools.size >= 128) || bytes > 1024 * 1024) { + let bytes = this.bytes - (previous ? this.size(key, previous) : 0) + this.size(key, snapshot) + let count = this.tools.size + (previous ? 0 : 1) + const evicted: string[] = [] + for (const [candidate, stored] of this.tools) { + if (count <= 128 && bytes <= 1024 * 1024) { + break + } + if (candidate !== key && stored.body.state !== 'running') { + evicted.push(candidate) + count -= 1 + bytes -= this.size(candidate, stored) + } + } + if (count > 128 || bytes > 1024 * 1024) { throw new Error('ACP tool snapshot budget exceeded') } + for (const candidate of evicted) { + this.tools.delete(candidate) + } + this.tools.delete(key) this.tools.set(key, snapshot) this.bytes = bytes } From fa85fc30c389374f8568992ed54eb392d03b6277 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:46:54 -0700 Subject: [PATCH 051/269] Forward ACP joins to the journal-derived timeline assembler --- src/main/acp/acp-session-update.ts | 4 +- src/main/acp/acp-timeline-generic.test.ts | 13 ++- src/main/acp/acp-timeline-joins.test.ts | 134 ++++++++++++++++++++++ src/main/acp/acp-timeline-translator.ts | 21 +++- src/main/acp/acp-tool-timeline.ts | 21 ++-- 5 files changed, 173 insertions(+), 20 deletions(-) create mode 100644 src/main/acp/acp-timeline-joins.test.ts diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index d314e72c1297..57eb2db9881c 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -19,7 +19,7 @@ export function acpSessionUpdate( replayUserBody?: AgentJournalMessageItem ): ProviderTimelineEvent[] { const update = notification.update - const join = turn === undefined ? {} : { join: { turn } } + const join = { join: { thread: notification.sessionId, ...(turn === undefined ? {} : { turn }) } } switch (update.sessionUpdate) { case 'agent_message_chunk': case 'agent_thought_chunk': @@ -53,7 +53,7 @@ export function acpSessionUpdate( : [] case 'tool_call': case 'tool_call_update': - return tools.translate(update, dialect, turn) + return tools.translate(update, dialect, join.join) case 'plan': return [ { diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts index 56de41ec7762..8cfdc3c38dec 100644 --- a/src/main/acp/acp-timeline-generic.test.ts +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -4,8 +4,7 @@ import { contextTokensFromUsage } from '../../shared/agent-session-context-usage import { closeProviderTimelineRigs, messageText, - openProviderTimelineRig, - providerItemId + openProviderTimelineRig } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { AcpTimelineTranslator, acpTurnEnd } from './acp-timeline-translator' @@ -101,7 +100,9 @@ describe('generic ACP translation', () => { // A settled journal row refuses a late provider update that says it is running again. update({ sessionUpdate: 'tool_call_update', toolCallId: 'tool-1', status: 'in_progress' }) apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) - const body = (await rig.row(providerItemId('item', 'tool:tool-1')))?.body + const body = (await rig.rows()).find( + (row) => row.body.kind === 'tool-call' && row.body.callId === 'tool-1' + )?.body expect(body).toMatchObject({ name: 'edit_file', state: 'completed', @@ -323,7 +324,11 @@ describe('generic ACP translation', () => { } update({ sessionUpdate: 'tool_call_update', toolCallId: 'active', status: 'failed' }) apply(translator.promptResult('send-1', { stopReason: 'cancelled' }, 1200)) - expect((await rig.row(providerItemId('item', 'tool:active')))?.body).toMatchObject({ + expect( + (await rig.rows()).find( + (row) => row.body.kind === 'tool-call' && row.body.callId === 'active' + )?.body + ).toMatchObject({ name: 'Active', input: { important: true }, state: 'failed' diff --git a/src/main/acp/acp-timeline-joins.test.ts b/src/main/acp/acp-timeline-joins.test.ts new file mode 100644 index 000000000000..2b9dafaff228 --- /dev/null +++ b/src/main/acp/acp-timeline-joins.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { createCodexProviderTimelineIdentityScheme } from '../codex/codex-provider-timeline-identity' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { AcpTimelineTranslator } from './acp-timeline-translator' + +afterEach(closeProviderTimelineRigs) + +describe('ACP joins through the v3 assembler', () => { + it('keeps provider message and tool ids apart across ACP sessions', async () => { + const rig = await openProviderTimelineRig() + for (const sessionId of ['session-a', 'session-b']) { + const translator = new AcpTimelineTranslator({ + sessionId, + journalItems: () => rig.journal.snapshot().items + }) + for (const update of [ + { + sessionUpdate: 'agent_message_chunk', + messageId: 'same-message', + content: { type: 'text', text: sessionId } + }, + { + sessionUpdate: 'tool_call', + toolCallId: 'same-tool', + title: sessionId, + status: 'completed' + } + ]) { + for (const event of translator.notification( + 'session/update', + { sessionId, update }, + 1100 + )) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + } + const rows = await rig.rows() + expect( + rows.filter((row) => row.body.kind === 'message').map((row) => messageText(row.body)) + ).toEqual(['session-a', 'session-b']) + expect(rows.flatMap((row) => (row.body.kind === 'tool-call' ? [row.body.name] : []))).toEqual([ + 'session-a', + 'session-b' + ]) + }) + + it('resumes named text in its existing ordinal row and closes the same joined stream', async () => { + const rig = await openProviderTimelineRig({ + scheme: createCodexProviderTimelineIdentityScheme({ + sessionId: 'session-timeline', + primaryThreadId: () => 'provider-1' + }), + ownThread: () => 'provider-1' + }) + const translator = new AcpTimelineTranslator({ + sessionId: 'provider-1', + journalItems: () => rig.journal.snapshot().items + }) + const apply = (events: ProviderTimelineEvent[]) => { + for (const event of events) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + const chunk = (text: string) => + translator.notification( + 'session/update', + { + sessionId: 'provider-1', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'm1', + content: { type: 'text', text } + } + }, + 1100 + ) + apply(translator.openPrompt('send-1', 1000)) + apply(chunk('Hel')) + const before = (await rig.rows()).find((row) => row.body.kind === 'message')! + rig.assembler = rig.restart({ generation: 'gen-2' }) + const delta = chunk('lo')[0]! + if (delta.type !== 'text.delta') { + throw new Error('Expected a translated text delta') + } + expect(delta.join).toEqual({ thread: 'provider-1', turn: 'prompt:send-1' }) + apply([delta, { type: 'text.close', item: delta.item, join: delta.join }]) + expect(rig.assembler.apply(delta).dropped).toBe('item-settled') + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages).toHaveLength(1) + expect(messages[0]!.itemId).toBe(before.itemId) + expect(messageText(messages[0]!.body)).toBe('Hello') + }) + + it('lets the assembler recover request incarnations and the open turn after a restart', async () => { + const rig = await openProviderTimelineRig() + const build = () => + new AcpTimelineTranslator({ + sessionId: 'provider-1', + journalItems: () => rig.journal.snapshot().items + }) + const params = { + sessionId: 'provider-1', + toolCall: { toolCallId: 'call-1', title: 'Run?' }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }] + } + const translator = build() + for (const event of translator.openPrompt('send-1', 1000)) { + rig.assembler.apply(event) + } + const event = translator.request('session/request_permission', params, 0).events[0]! + if (event.type !== 'request.open') { + throw new Error('Expected a translated request') + } + expect(event.join).toEqual({ thread: 'provider-1' }) + rig.assembler.apply(event) + rig.assembler.apply({ type: 'request.withdrawn', request: event.request }) + const first = (await rig.rows()).find((row) => row.body.kind === 'approval')! + rig.assembler = rig.restart({ generation: 'gen-2' }) + for (const reopened of build().request('session/request_permission', params, 0).events) { + expect(rig.assembler.apply(reopened).admission.accepted).toBe(true) + } + const approvals = (await rig.rows()).filter((row) => row.body.kind === 'approval') + expect(approvals).toHaveLength(2) + expect(approvals[1]!.itemId).not.toBe(first.itemId) + expect(approvals[1]!.turnScope).toEqual(first.turnScope) + expect(approvals[1]!.body).toMatchObject({ resolution: { state: 'pending' } }) + }) +}) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 74a8c2f19335..fc764b0797eb 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -69,7 +69,12 @@ export class AcpTimelineTranslator { const turn = `prompt:${clientMessageId}` this.prompt = { turn } return [ - { type: 'input.accepted', clientMessageId, requestedAt: at, join: { turn } }, + { + type: 'input.accepted', + clientMessageId, + requestedAt: at, + join: { thread: this.options.sessionId, turn } + }, { type: 'turn.open', turn, at } ] } @@ -84,14 +89,14 @@ export class AcpTimelineTranslator { this.dialect.promptUsage?.(result, at) ?? (result.usage ? acpResponseUsage(result.usage, at) : undefined) const events: ProviderTimelineEvent[] = usage - ? [{ type: 'context.usage', usage, join: { turn } }] + ? [{ type: 'context.usage', usage, join: { thread: this.options.sessionId, turn } }] : [] if (!['end_turn', 'cancelled'].includes(result.stopReason)) { events.push({ type: 'provider.frame', frameKind: `prompt:${result.stopReason}`, payload: result, - join: { turn } + join: { thread: this.options.sessionId, turn } }) } events.push( @@ -184,7 +189,9 @@ export class AcpTimelineTranslator { substantive || extension?.end !== undefined, isReplay ) - const join = turn === undefined ? {} : { join: { turn } } + const join = { + join: { thread: this.options.sessionId, ...(turn === undefined ? {} : { turn }) } + } if (extension?.usage) { events.push({ type: 'context.usage', usage: extension.usage, ...join }) } @@ -243,9 +250,10 @@ export class AcpTimelineTranslator { method === 'session/request_permission' ? acpPermissionPresentation(params) : this.dialect.request?.(method, params) + const join = { thread: this.options.sessionId } if (!presentation) { return { - events: [{ type: 'provider.frame', frameKind: `request:${method}`, payload: params }] + events: [{ type: 'provider.frame', frameKind: `request:${method}`, payload: params, join }] } } return { @@ -254,7 +262,8 @@ export class AcpTimelineTranslator { { type: 'request.open', request: `${method}:${JSON.stringify(id)}`, - body: presentation.body + body: presentation.body, + join } ] } diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts index 71ce27647e42..b1c72d6d063e 100644 --- a/src/main/acp/acp-tool-timeline.ts +++ b/src/main/acp/acp-tool-timeline.ts @@ -6,7 +6,10 @@ import { boundToolInput, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' -import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { + ProviderTimelineEvent, + ProviderTimelineJoin +} from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect } from './acp-dialects/acp-dialect' import type { ToolCallUpdate } from './generated/acp-protocol.generated' @@ -71,7 +74,11 @@ export class AcpToolTimeline { private readonly tools = new Map() private bytes = 0 - translate(update: ToolCallUpdate, dialect: AcpDialect, turn?: string): ProviderTimelineEvent[] { + translate( + update: ToolCallUpdate, + dialect: AcpDialect, + join: ProviderTimelineJoin + ): ProviderTimelineEvent[] { const previous = this.tools.get(update.toolCallId) const output = outputText(update) const state = @@ -103,19 +110,17 @@ export class AcpToolTimeline { : {} : { output: boundPayload(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) }) } - const joinedTurn = previous?.turn ?? turn const snapshot: ToolSnapshot = { body, - ...(joinedTurn === undefined ? {} : { turn: joinedTurn }) + ...(join.turn === undefined ? {} : { turn: join.turn }) } this.remember(update.toolCallId, snapshot) - const join = joinedTurn === undefined ? {} : { join: { turn: joinedTurn } } const events: ProviderTimelineEvent[] = [ { type: state === 'running' ? (previous ? 'item.update' : 'item.open') : 'item.close', item: `tool:${update.toolCallId}`, body, - ...join + join } ] for (const content of update.content ?? []) { @@ -131,14 +136,14 @@ export class AcpToolTimeline { DEFAULT_JOURNAL_PAYLOAD_LIMITS ) }, - ...join + join }) } else if (content.type === 'terminal' || content.content.type !== 'text') { events.push({ type: 'provider.frame', frameKind: `tool-content:${content.type}`, payload: content, - ...join + join }) } } From c4a072db6c32307db4584c8fd6d438358418d3e6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:05:55 -0700 Subject: [PATCH 052/269] Use exact ACP prompt identity and recover incomplete timeline replay --- src/main/acp/acp-context-usage.ts | 34 +- src/main/acp/acp-dialects/acp-dialect.ts | 24 +- src/main/acp/acp-dialects/grok-dialect.ts | 160 +++++--- src/main/acp/acp-dialects/grok-requests.ts | 36 +- src/main/acp/acp-fixture-privacy.test.ts | 89 +++++ src/main/acp/acp-journal-turns.ts | 52 +++ src/main/acp/acp-prompt-turns.ts | 67 ++++ src/main/acp/acp-session-update.ts | 25 +- src/main/acp/acp-timeline-dialect.test.ts | 42 +- .../acp/acp-timeline-fixture.test-support.ts | 6 +- src/main/acp/acp-timeline-generic.test.ts | 32 +- src/main/acp/acp-timeline-joins.test.ts | 4 +- src/main/acp/acp-timeline-recovery.test.ts | 187 +++++++++ src/main/acp/acp-timeline-requests.ts | 64 ++- src/main/acp/acp-timeline-routing.test.ts | 122 ++++++ src/main/acp/acp-timeline-translator.ts | 365 +++++++++--------- src/main/acp/acp-tool-timeline.ts | 22 +- src/main/acp/acp-turn-messages.ts | 35 ++ src/main/acp/fixtures/s1-basic.jsonl | 21 +- .../acp/fixtures/s1-full-notifications.jsonl | 142 +++++++ src/main/acp/fixtures/s2-permission.jsonl | 28 +- src/main/acp/fixtures/s3-cancel.jsonl | 18 +- src/main/acp/fixtures/s4-resume.jsonl | 38 +- src/main/acp/fixtures/s5-plan-approved.jsonl | 21 +- src/main/acp/fixtures/s6-background.jsonl | 14 +- 25 files changed, 1255 insertions(+), 393 deletions(-) create mode 100644 src/main/acp/acp-fixture-privacy.test.ts create mode 100644 src/main/acp/acp-journal-turns.ts create mode 100644 src/main/acp/acp-prompt-turns.ts create mode 100644 src/main/acp/acp-timeline-recovery.test.ts create mode 100644 src/main/acp/acp-timeline-routing.test.ts create mode 100644 src/main/acp/acp-turn-messages.ts create mode 100644 src/main/acp/fixtures/s1-full-notifications.jsonl diff --git a/src/main/acp/acp-context-usage.ts b/src/main/acp/acp-context-usage.ts index e290d1056d05..000b915295bc 100644 --- a/src/main/acp/acp-context-usage.ts +++ b/src/main/acp/acp-context-usage.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import type { AgentSessionContextUsage } from '../../shared/agent-session-context-usage' -import type { Usage, UsageUpdate } from './generated/acp-protocol.generated' +import type { UsageUpdate } from './generated/acp-protocol.generated' export function acpWindowUsage(update: UsageUpdate, at: number): AgentSessionContextUsage { return { @@ -18,38 +18,6 @@ export function acpWindowUsage(update: UsageUpdate, at: number): AgentSessionCon } } -export function acpResponseUsage(usage: Usage, at: number): AgentSessionContextUsage { - return tokenEstimate( - usage.inputTokens, - usage.outputTokens, - usage.cachedReadTokens ?? 0, - usage.cachedWriteTokens ?? 0, - at - ) -} - -export function tokenEstimate( - input: number, - output: number, - cachedRead: number, - cachedWrite: number, - at: number -): AgentSessionContextUsage { - return { - used: { - kind: 'estimate', - capturedAt: at, - usage: { - // ACP input includes cached tokens; the shared estimate adds its cache fields. - inputTokens: Math.max(0, input - cachedRead - cachedWrite), - cacheReadInputTokens: cachedRead, - cacheCreationInputTokens: cachedWrite, - outputTokens: output - } - } - } -} - export const acpNotificationEnvelopeSchema = z.looseObject({ sessionId: z.string(), update: z.looseObject({ sessionUpdate: z.string() }), diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index c4bcadc13232..04f9c4c3997a 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -8,20 +8,24 @@ export type AcpRequestPresentation = { reply(response: AgentSessionPromptResponse | null): unknown } -export type AcpDialectNotification = { - turn?: string - agentInitiated?: boolean - replay?: boolean - at?: number - end?: { stopReason: string; durationMs?: number } - usage?: AgentSessionContextUsage -} +export type AcpDialectNotification = + | { disposition: 'ignore' } + | { + disposition: 'map' + turn?: string + replay?: boolean + at?: number + started?: boolean + end?: { stopReason: string; durationMs?: number } + usage?: AgentSessionContextUsage + } -/** Hooks only interpret provider extensions; lifecycle and row identity stay shared. */ +/** Hooks interpret extensions; lifecycle and row identity stay shared. */ export type AcpDialect = { + injectedPromptIdentity?: true toolName?(update: ToolCallUpdate): string | undefined notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined - promptUsage?(result: unknown, at: number): AgentSessionContextUsage | undefined + contextWindow?(models: unknown): number | undefined request?(method: string, params: unknown): AcpRequestPresentation | undefined } diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index 48b1233d7f4e..700676d6b581 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -1,16 +1,9 @@ import { z } from 'zod' -import { acpNotificationEnvelopeSchema, tokenEstimate } from '../acp-context-usage' -import type { AcpDialect } from './acp-dialect' +import { acpNotificationEnvelopeSchema } from '../acp-context-usage' +import type { AcpDialect, AcpDialectNotification } from './acp-dialect' import { grokRequest } from './grok-requests' const tokenCount = z.number().int().nonnegative() -const usageSchema = z.looseObject({ - inputTokens: tokenCount, - outputTokens: tokenCount.default(0), - cachedReadTokens: tokenCount.default(0), - cacheCreationTokens: tokenCount.default(0) -}) -const promptUsageSchema = z.object({ _meta: z.looseObject({ usage: usageSchema.optional() }) }) const toolMetaSchema = z.object({ 'x.ai/tool': z.object({ name: z.string().min(1) }) }) const turnMetaSchema = z.looseObject({ promptId: z.string().optional(), @@ -20,69 +13,118 @@ const completionSchema = z.looseObject({ sessionUpdate: z.literal('turn_completed'), prompt_id: z.string(), stop_reason: z.string(), - usage: usageSchema.optional(), elapsed_ms: tokenCount.optional() }) +const responseSchema = z.object({ + sessionUpdate: z.literal('response_completed'), + usage: z.object({ + input_tokens: tokenCount, + output_tokens: tokenCount.default(0), + cache_read_input_tokens: tokenCount.default(0), + cache_creation_input_tokens: tokenCount.default(0) + }) +}) +const queueSchema = z.object({ sessionId: z.string(), runningPromptId: z.string().nullish() }) +const modelsSchema = z.object({ + currentModelId: z.string().optional(), + availableModels: z + .array( + z.object({ + modelId: z.string(), + _meta: z.object({ totalContextTokens: z.number().int().positive() }).optional() + }) + ) + .optional() +}) -function usageFrom(value: unknown, at: number) { - const parsed = usageSchema.safeParse(value) +function contextWindow(models: unknown): number | undefined { + const parsed = modelsSchema.safeParse(models) if (!parsed.success) { return undefined } - const usage = parsed.data - return tokenEstimate( - usage.inputTokens, - usage.outputTokens, - usage.cachedReadTokens, - usage.cacheCreationTokens, - at + const { currentModelId, availableModels = [] } = parsed.data + return ( + availableModels.find((model) => model.modelId === currentModelId)?._meta?.totalContextTokens ?? + availableModels.find((model) => model._meta)?._meta?.totalContextTokens ) } +function notification( + method: string, + params: unknown, + at: number +): AcpDialectNotification | undefined { + const canonical = method.startsWith('_') ? method.slice(1) : method + if (canonical === 'x.ai/models/update') { + const tokens = contextWindow(params) + return tokens === undefined + ? { disposition: 'ignore' } + : { disposition: 'map', usage: { window: { tokens, capturedAt: at } } } + } + if (canonical === 'x.ai/queue/changed') { + const parsed = queueSchema.safeParse(params) + return parsed.success && parsed.data.runningPromptId + ? { disposition: 'map', turn: parsed.data.runningPromptId, started: true } + : { disposition: 'ignore' } + } + if ( + method !== 'session/update' && + !['x.ai/session_notification', 'x.ai/session/update'].includes(canonical) + ) { + return canonical.startsWith('x.ai/') ? { disposition: 'ignore' } : undefined + } + const parsed = acpNotificationEnvelopeSchema.safeParse(params) + if (!parsed.success) { + return method === 'session/update' ? undefined : { disposition: 'ignore' } + } + const envelope = parsed.data + const meta = turnMetaSchema.safeParse(envelope._meta) + const completion = completionSchema.safeParse(envelope.update) + const response = responseSchema.safeParse(envelope.update) + if (method !== 'session/update' && !completion.success && !response.success) { + return { disposition: 'ignore' } + } + const turn = completion.success + ? completion.data.prompt_id + : meta.success + ? meta.data.promptId + : undefined + return { + disposition: 'map', + ...(turn === undefined ? {} : { turn }), + ...(meta.success && meta.data.turnStartMs !== undefined ? { at: meta.data.turnStartMs } : {}), + replay: envelope._meta?.isReplay === true, + ...(completion.success + ? { + end: { stopReason: completion.data.stop_reason, durationMs: completion.data.elapsed_ms } + } + : {}), + ...(response.success + ? { + usage: { + used: { + kind: 'estimate', + capturedAt: at, + usage: { + inputTokens: response.data.usage.input_tokens, + outputTokens: response.data.usage.output_tokens, + cacheReadInputTokens: response.data.usage.cache_read_input_tokens, + cacheCreationInputTokens: response.data.usage.cache_creation_input_tokens + } + } + } + } + : {}) + } +} + export const GROK_ACP_DIALECT: AcpDialect = { + injectedPromptIdentity: true, toolName: (update) => { const parsed = toolMetaSchema.safeParse(update._meta) return parsed.success ? parsed.data['x.ai/tool'].name : undefined }, request: grokRequest, - promptUsage: (result, at) => { - const parsed = promptUsageSchema.safeParse(result) - return parsed.success ? usageFrom(parsed.data._meta.usage ?? parsed.data._meta, at) : undefined - }, - notification: (method, params, at) => { - if ( - !['session/update', '_x.ai/session_notification', '_x.ai/session/update'].includes(method) - ) { - return undefined - } - const parsed = acpNotificationEnvelopeSchema.safeParse(params) - if (!parsed.success) { - return undefined - } - const envelope = parsed.data - const meta = turnMetaSchema.safeParse(envelope._meta) - const completion = completionSchema.safeParse(envelope.update) - const turn = completion.success - ? completion.data.prompt_id - : meta.success - ? meta.data.promptId - : undefined - const usage = completion.success ? usageFrom(completion.data.usage, at) : undefined - return { - ...(turn === undefined ? {} : { turn, agentInitiated: turn.startsWith('task-completed-') }), - ...(meta.success && meta.data.turnStartMs !== undefined ? { at: meta.data.turnStartMs } : {}), - replay: envelope._meta?.isReplay === true, - ...(completion.success - ? { - end: { - stopReason: completion.data.stop_reason, - ...(completion.data.elapsed_ms === undefined - ? {} - : { durationMs: completion.data.elapsed_ms }) - } - } - : {}), - ...(usage === undefined ? {} : { usage }) - } - } + notification, + contextWindow } diff --git a/src/main/acp/acp-dialects/grok-requests.ts b/src/main/acp/acp-dialects/grok-requests.ts index 7f6c0d15f950..26dbee0fae25 100644 --- a/src/main/acp/acp-dialects/grok-requests.ts +++ b/src/main/acp/acp-dialects/grok-requests.ts @@ -23,7 +23,7 @@ const questionRequestSchema = z.object({ const planRequestSchema = z.object({ sessionId: z.string(), toolCallId: z.string(), - planContent: z.string() + planContent: z.string().nullish() }) function questionRequest(params: unknown): AcpRequestPresentation { @@ -59,7 +59,12 @@ function questionRequest(params: unknown): AcpRequestPresentation { response.kind === 'answers' ? response.answers : legacyAgentSessionQuestionAnswers(body, response.optionId) - if (!answers || !isValidAgentSessionQuestionAnswers(questions, answers)) { + const choices = answers?.map((answer) => + answer.optionIds.length > 0 + ? { questionId: answer.questionId, optionIds: answer.optionIds } + : answer + ) + if (!answers || !choices || !isValidAgentSessionQuestionAnswers(questions, choices)) { throw new AcpRpcError(-32602, 'Question answer must answer every offered question') } return { @@ -70,12 +75,24 @@ function questionRequest(params: unknown): AcpRequestPresentation { const values = answer.optionIds.map( (id) => question.options.find((option) => option.id === id)!.label ) - if (answer.other?.trim()) { - values.push(answer.other.trim()) + if (values.length === 0 && answer.other?.trim()) { + values.push('Other') } return [question.question, question.multiSelect ? values : values[0]] }) - ) + ), + ...(answers.some((answer) => answer.other?.trim()) + ? { + annotations: Object.fromEntries( + questions.flatMap((question) => { + const notes = answers + .find((answer) => answer.questionId === question.id) + ?.other?.trim() + return notes ? [[question.question, { notes }]] : [] + }) + ) + } + : {}) } } } @@ -91,7 +108,10 @@ function planRequest(params: unknown): AcpRequestPresentation { kind: 'approval', title: 'Approve plan', detail: null, - subject: { kind: 'plan', text: parsed.data.planContent }, + subject: { + kind: 'plan', + text: parsed.data.planContent ?? 'The agent exited plan mode without writing a plan.' + }, options: [ { id: 'approved', label: 'Approve plan' }, { id: 'request_changes', label: 'Request changes' } @@ -114,10 +134,10 @@ function planRequest(params: unknown): AcpRequestPresentation { } export function grokRequest(method: string, params: unknown): AcpRequestPresentation | undefined { - if (method === '_x.ai/ask_user_question') { + if (['_x.ai/ask_user_question', 'x.ai/ask_user_question'].includes(method)) { return questionRequest(params) } - if (method === '_x.ai/exit_plan_mode') { + if (['_x.ai/exit_plan_mode', 'x.ai/exit_plan_mode'].includes(method)) { return planRequest(params) } return undefined diff --git a/src/main/acp/acp-fixture-privacy.test.ts b/src/main/acp/acp-fixture-privacy.test.ts new file mode 100644 index 000000000000..b683ec1eeb1e --- /dev/null +++ b/src/main/acp/acp-fixture-privacy.test.ts @@ -0,0 +1,89 @@ +import { readFile, readdir } from 'node:fs/promises' +import { describe, expect, it } from 'vitest' + +const privateText = + /\/Users\/|\/home\/|[a-z]:[\\/]+Users[\\/]|brennan|orca-qa|native-chat-other-agents|[\w.+-]+@[\w.-]+\.[a-z]{2,}|\bhostname\b|\b[\w-]+\.(?:local|lan)\b|signature|subscription_tier|skills?/i + +function decodedStrings(value: unknown, depth = 0): string[] { + if (depth > 8) { + return [] + } + if (Array.isArray(value)) { + if ( + value.length > 0 && + value.every( + (entry) => + typeof entry === 'number' && Number.isInteger(entry) && entry >= 0 && entry <= 255 + ) + ) { + return decodedStrings(Buffer.from(value).toString('utf8'), depth + 1) + } + return value.flatMap((entry) => decodedStrings(entry, depth + 1)) + } + if (typeof value === 'object' && value !== null) { + return Object.entries(value).flatMap(([key, entry]) => [ + key, + ...decodedStrings(entry, depth + 1) + ]) + } + if (typeof value !== 'string') { + return [] + } + const texts = [value] + try { + const decoded = decodeURIComponent(value) + if (decoded !== value) { + texts.push(...decodedStrings(decoded, depth + 1)) + } + } catch { + /* Provider text need not be URI encoded. */ + } + try { + const decoded: unknown = JSON.parse(value) + if (decoded !== value) { + texts.push(...decodedStrings(decoded, depth + 1)) + } + } catch { + /* Ordinary text is not JSON. */ + } + for (const token of value.match(/[A-Za-z0-9+/_-]{16,}={0,2}/g) ?? []) { + const decoded = Buffer.from(token, 'base64url').toString('utf8') + if (decoded !== token && /^[\x20-\x7e\r\n\t]+$/.test(decoded)) { + texts.push(...decodedStrings(decoded, depth + 1)) + } + } + return texts +} + +describe('ACP recording privacy', () => { + it.each(['/Users/private/project', '/home/private/project', 'C:\\Users\\private\\project'])( + 'detects home paths after nested JSON, URL, base64 and byte encoding: %s', + (path) => { + for (const value of [ + path, + encodeURIComponent(path), + Buffer.from(path).toString('base64'), + [...Buffer.from(path)], + JSON.stringify({ nested: JSON.stringify(path) }) + ]) { + expect(decodedStrings(value).some((text) => privateText.test(text))).toBe(true) + } + } + ) + + it('contains no personal metadata or home-directory paths in any decoded fixture', async () => { + const directory = new URL('./fixtures/', import.meta.url) + const names = (await readdir(directory)).filter((name) => name.endsWith('.jsonl')) + expect(names.length).toBeGreaterThanOrEqual(6) + for (const name of names) { + const content = await readFile(new URL(name, directory), 'utf8') + for (const [index, line] of content.trim().split('\n').entries()) { + const value: unknown = JSON.parse(line) + expect( + decodedStrings(value).some((text) => privateText.test(text)), + `${name}:${index + 1}` + ).toBe(false) + } + } + }) +}) diff --git a/src/main/acp/acp-journal-turns.ts b/src/main/acp/acp-journal-turns.ts new file mode 100644 index 000000000000..15d48e78e8c4 --- /dev/null +++ b/src/main/acp/acp-journal-turns.ts @@ -0,0 +1,52 @@ +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { providerTimelineKeyPart } from '../native-chat/agent-session-timeline/provider-timeline-identity' + +/** Read provider ids back from the assembler's existing turn records. */ +export function acpJournalTurnKey(row: AgentJournalRenderItem): string | undefined { + const turn = readAgentJournalTurn(row.body) + if (!turn) { + return undefined + } + if (!turn.turnId.startsWith('p:')) { + return turn.turnId + } + const value = turn.turnId.slice(turn.turnId.lastIndexOf(':') + 1) + try { + return decodeURIComponent(value) + } catch { + return undefined + } +} + +export function acpJournalTurnIsSettled( + rows: readonly AgentJournalRenderItem[], + key: string +): boolean { + return rows.some((row) => { + const turn = readAgentJournalTurn(row.body) + return ( + turn !== null && + turn.state !== 'running' && + (turn.turnId === key || turn.turnId.endsWith(`:${providerTimelineKeyPart(key)}`)) + ) + }) +} + +export function acpJournalTurnHasUser( + rows: readonly AgentJournalRenderItem[], + key: string +): boolean { + const record = rows.find((row) => acpJournalTurnKey(row) === key) + if (!record) { + return false + } + const turn = readAgentJournalTurn(record.body) + return rows.some( + (row) => + row.body.kind === 'message' && + row.body.role === 'user' && + (row.itemId === turn?.userItemId || + (row.turnScope?.kind === 'turn' && row.turnScope.turnItemId === record.itemId)) + ) +} diff --git a/src/main/acp/acp-prompt-turns.ts b/src/main/acp/acp-prompt-turns.ts new file mode 100644 index 000000000000..6dd867688c87 --- /dev/null +++ b/src/main/acp/acp-prompt-turns.ts @@ -0,0 +1,67 @@ +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' + +export type AcpPromptTurn = { + clientMessageId: string + turn: string + requestedAt: number + opened: boolean + durationMs?: number +} + +/** An injected identity is known before any provider output arrives. */ +export class AcpPromptTurns { + current?: AcpPromptTurn + + constructor( + private readonly sessionId: string, + private readonly injected: boolean + ) {} + + open(clientMessageId: string, at: number): { promptId: string; events: ProviderTimelineEvent[] } { + if (this.current) { + throw new Error('ACP prompt overlaps a prompt or load') + } + const turn = `prompt:${clientMessageId}` + this.current = { clientMessageId, turn, requestedAt: at, opened: false } + return { promptId: turn, events: this.injected ? [] : this.start(turn, at) } + } + + start(turn: string, at: number): ProviderTimelineEvent[] { + const prompt = this.current + if (prompt?.turn !== turn || prompt.opened) { + return [] + } + prompt.opened = true + return [ + { type: 'turn.open', turn, at }, + { + type: 'input.accepted', + clientMessageId: prompt.clientMessageId, + requestedAt: prompt.requestedAt, + join: { thread: this.sessionId, turn } + } + ] + } +} + +export function acpTurnEnd( + turn: string, + stopReason: string, + at: number, + durationMs?: number +): ProviderTimelineEvent { + return { + type: 'turn.end', + turn, + at, + state: stopReason === 'cancelled' ? 'interrupted' : 'completed', + ...(stopReason === 'end_turn' + ? { outcome: 'success' as const } + : stopReason === 'cancelled' + ? { outcome: 'cancellation' as const } + : ['refusal', 'max_tokens', 'max_turn_requests', 'error', 'rate_limit'].includes(stopReason) + ? { outcome: 'failure' as const } + : {}), + ...(durationMs === undefined ? {} : { durationMs }) + } +} diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index 57eb2db9881c..b9fd57e93736 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -16,20 +16,37 @@ export function acpSessionUpdate( replay: boolean, tools: AcpToolTimeline, dialect: AcpDialect, - replayUserBody?: AgentJournalMessageItem + replayUserBody?: AgentJournalMessageItem, + messageKey?: string ): ProviderTimelineEvent[] { const update = notification.update const join = { join: { thread: notification.sessionId, ...(turn === undefined ? {} : { turn }) } } switch (update.sessionUpdate) { case 'agent_message_chunk': case 'agent_thought_chunk': + if (replay && messageKey && update.content.type === 'text') { + return [ + { + type: 'item.update', + item: messageKey, + body: { + kind: 'message', + role: update.sessionUpdate === 'agent_thought_chunk' ? 'reasoning' : 'assistant', + blocks: [{ type: 'text', text: update.content.text }] + }, + ...join + } + ] + } return update.content.type === 'text' ? [ { type: 'text.delta', - item: update.messageId - ? { id: `message:${update.messageId}` } - : { stream: update.sessionUpdate }, + item: messageKey + ? { id: messageKey } + : update.messageId + ? { id: `message:${update.messageId}` } + : { stream: update.sessionUpdate }, channel: update.sessionUpdate === 'agent_thought_chunk' ? 'reasoning' : 'assistant', text: update.content.text, ...join diff --git a/src/main/acp/acp-timeline-dialect.test.ts b/src/main/acp/acp-timeline-dialect.test.ts index c5b3b59087de..23fd7024ff13 100644 --- a/src/main/acp/acp-timeline-dialect.test.ts +++ b/src/main/acp/acp-timeline-dialect.test.ts @@ -42,7 +42,18 @@ const question = { describe('ACP dialect request and turn boundaries', () => { it('preserves provider permission labels, validates selected ids, and cancels unanswered rows on turn end', async () => { const { rig, translator, apply } = await dialectRig() - apply(translator.openPrompt('send-1', 1000)) + apply(translator.openPrompt('send-1', 1000).events) + apply( + translator.notification( + 'session/update', + { + sessionId: 'provider-1', + _meta: { promptId: 'prompt:send-1' }, + update: { sessionUpdate: 'tool_call', toolCallId: 'call-1', title: 'Write example' } + }, + 1001 + ) + ) const request = translator.request( 'session/request_permission', { @@ -80,7 +91,22 @@ describe('ACP dialect request and turn boundaries', () => { ).toEqual({ outcome: 'accepted', answers: { 'Choose a test?': 'Integration' } }) expect( ask.reply({ kind: 'answers', answers: [{ questionId: 'q1', optionIds: [], other: 'Smoke' }] }) - ).toEqual({ outcome: 'accepted', answers: { 'Choose a test?': 'Smoke' } }) + ).toEqual({ + outcome: 'accepted', + answers: { 'Choose a test?': 'Other' }, + annotations: { 'Choose a test?': { notes: 'Smoke' } } + }) + const alias = translator.request('x.ai/ask_user_question', question, 3).presentation! + expect( + alias.reply({ + kind: 'answers', + answers: [{ questionId: 'q1', optionIds: ['o1'], other: 'Also run smoke checks' }] + }) + ).toEqual({ + outcome: 'accepted', + answers: { 'Choose a test?': 'Unit' }, + annotations: { 'Choose a test?': { notes: 'Also run smoke checks' } } + }) expect(ask.reply(null)).toEqual({ outcome: 'cancelled' }) expect(() => ask.reply({ kind: 'answers', answers: [] })).toThrow('every offered question') expect(() => @@ -124,10 +150,14 @@ describe('ACP dialect request and turn boundaries', () => { it('keeps client prompt settlement on the response, and never gives an autonomous turn invented success', async () => { const { rig, translator, apply, notification } = await dialectRig() - apply(translator.openPrompt('send-1', 1000)) + apply(translator.openPrompt('send-1', 1000).events) notification('_x.ai/session_notification', { sessionId: 'provider-1', - update: { sessionUpdate: 'turn_completed', prompt_id: 'regular', stop_reason: 'end_turn' } + update: { + sessionUpdate: 'turn_completed', + prompt_id: 'prompt:send-1', + stop_reason: 'end_turn' + } }) expect((await rig.turns())[0]!.state).toBe('running') apply(translator.promptResult('send-1', { stopReason: 'cancelled' }, 1200)) @@ -155,14 +185,14 @@ describe('ACP dialect request and turn boundaries', () => { it('keeps live autonomous output while suppressing marked replay during load', async () => { const { rig, translator, apply, notification } = await dialectRig() - apply(translator.openPrompt('send-1', 1000)) + apply(translator.openPrompt('send-1', 1000).events) apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) await rig.rows() translator.beginLoad() notification('session/update', { sessionId: 'provider-1', update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Old' } }, - _meta: { isReplay: true, promptId: 'old' } + _meta: { isReplay: true, promptId: 'prompt:send-1' } }) notification('session/update', { sessionId: 'provider-1', diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts index ffe89fcc17b5..3e2bdd39afcb 100644 --- a/src/main/acp/acp-timeline-fixture.test-support.ts +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -98,7 +98,7 @@ export async function openAcpFixtureRig() { providerSessionId = session.sessionId lane = translator() } - apply(lane.openPrompt(`${frame.process}:${message.id}`, at)) + apply(lane.openPrompt(`${frame.process}:${message.id}`, at).events) } else if (direction === 'in' && message.method && message.id !== undefined) { const request = lane.request(message.method, message.params, message.id) apply(request.events) @@ -146,6 +146,10 @@ export async function openAcpFixtureRig() { } else if (direction === 'in' && message.method) { apply(lane.notification(message.method, message.params, at)) } else if (direction === 'in') { + const models = z.object({ models: z.unknown() }).safeParse(message.result) + if (models.success) { + apply(lane.contextModels(models.data.models, at)) + } const result = PromptResponseSchema.safeParse(message.result) if (result.success) { apply(lane.promptResult(`${frame.process}:${message.id}`, result.data, at)) diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts index 8cfdc3c38dec..2b9ebb51dcd3 100644 --- a/src/main/acp/acp-timeline-generic.test.ts +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -30,7 +30,7 @@ async function genericRig() { describe('generic ACP translation', () => { it('keeps named text across non-text barriers, splits anonymous text, and assembles reasoning', async () => { const { rig, translator, apply, update } = await genericRig() - apply(translator.openPrompt('send-1', 1000)) + apply(translator.openPrompt('send-1', 1000).events) update({ sessionUpdate: 'agent_message_chunk', messageId: 'm1', @@ -71,7 +71,7 @@ describe('generic ACP translation', () => { it('merges tool snapshots, bounds input/output and emits a separately keyed valid diff', async () => { const { rig, translator, apply, update } = await genericRig() - apply(translator.openPrompt('send-1', 1000)) + apply(translator.openPrompt('send-1', 1000).events) const oldText = 'same\nbefore\n' const newText = 'same\nafter\n' update({ @@ -117,9 +117,9 @@ describe('generic ACP translation', () => { expect(applyPatch(oldText, diff.patch.head)).toBe(newText) }) - it('records context window facts and does not double-count cached tokens in prompt usage', async () => { + it('keeps context occupancy from usage_update instead of aggregate prompt usage', async () => { const { rig, translator, apply, update } = await genericRig() - apply(translator.openPrompt('send-1', 1000)) + apply(translator.openPrompt('send-1', 1000).events) update({ sessionUpdate: 'usage_update', used: 42, size: 100 }) apply( translator.promptResult( @@ -142,23 +142,23 @@ describe('generic ACP translation', () => { expect(turn.contextUsage?.used).toMatchObject({ kind: 'estimate', usage: { - inputTokens: 20, - cacheReadInputTokens: 20, - cacheCreationInputTokens: 10, - outputTokens: 5 + inputTokens: 42, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0 } }) if (turn.contextUsage?.used?.kind !== 'estimate') { throw new Error('Missing usage') } - expect(contextTokensFromUsage(turn.contextUsage.used.usage)).toBe(50) + expect(contextTokensFromUsage(turn.contextUsage.used.usage)).toBe(42) }) it.each(['refusal', 'max_tokens', 'max_turn_requests'] as const)( - 'records %s as provider failure with the original stop reason', + 'records %s as provider failure without an extra status row', async (stopReason) => { const { rig, translator, apply } = await genericRig() - apply(translator.openPrompt('send-1', 1000)) + apply(translator.openPrompt('send-1', 1000).events) apply(translator.promptResult('send-1', { stopReason }, 1200)) expect((await rig.turns())[0]).toMatchObject({ state: 'completed', outcome: 'failure' }) expect( @@ -166,7 +166,7 @@ describe('generic ACP translation', () => { (row) => row.body.kind === 'status' && row.body.providerFrame?.kind === `prompt:${stopReason}` ) - ).toBe(true) + ).toBe(false) } ) @@ -234,7 +234,7 @@ describe('generic ACP translation', () => { it('requires an explicit journal decision for marked replay and uses typed standard events', async () => { const { rig, translator, apply } = await genericRig() - expect(() => + expect( translator.notification( 'session/update', { @@ -247,8 +247,8 @@ describe('generic ACP translation', () => { }, 1100 ) - ).toThrow('beginLoad') - apply(translator.openPrompt('send-1', 1000)) + ).toEqual([]) + apply(translator.openPrompt('send-1', 1000).events) apply( translator.sessionEvent( { @@ -301,7 +301,7 @@ describe('generic ACP translation', () => { it('evicts completed snapshots during long turns while preserving active tool input', async () => { const { rig, translator, apply, update } = await genericRig() - apply(translator.openPrompt('send-1', 1000)) + apply(translator.openPrompt('send-1', 1000).events) update({ sessionUpdate: 'tool_call', toolCallId: 'active', diff --git a/src/main/acp/acp-timeline-joins.test.ts b/src/main/acp/acp-timeline-joins.test.ts index 2b9dafaff228..af14c305f1e3 100644 --- a/src/main/acp/acp-timeline-joins.test.ts +++ b/src/main/acp/acp-timeline-joins.test.ts @@ -80,7 +80,7 @@ describe('ACP joins through the v3 assembler', () => { }, 1100 ) - apply(translator.openPrompt('send-1', 1000)) + apply(translator.openPrompt('send-1', 1000).events) apply(chunk('Hel')) const before = (await rig.rows()).find((row) => row.body.kind === 'message')! rig.assembler = rig.restart({ generation: 'gen-2' }) @@ -110,7 +110,7 @@ describe('ACP joins through the v3 assembler', () => { options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }] } const translator = build() - for (const event of translator.openPrompt('send-1', 1000)) { + for (const event of translator.openPrompt('send-1', 1000).events) { rig.assembler.apply(event) } const event = translator.request('session/request_permission', params, 0).events[0]! diff --git a/src/main/acp/acp-timeline-recovery.test.ts b/src/main/acp/acp-timeline-recovery.test.ts new file mode 100644 index 000000000000..e300d7a3e203 --- /dev/null +++ b/src/main/acp/acp-timeline-recovery.test.ts @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { contextTokensFromUsage } from '../../shared/agent-session-context-usage' +import { + closeProviderTimelineRigs, + messageText +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' +import { AcpTimelineTranslator } from './acp-timeline-translator' +import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' + +afterEach(closeProviderTimelineRigs) + +describe('ACP reviewed traffic and recovery', () => { + it('replays every notification in the full recording without extension status rows or persisted signatures', async () => { + const f = await openAcpFixtureRig() + const frames = await readAcpFixture('s1-full-notifications') + expect(frames).toHaveLength(142) + const rows = await f.feed(frames) + expect(rows.filter((row) => row.body.kind === 'status')).toEqual([]) + expect(rows.filter((row) => row.body.kind === 'tool-call')).toHaveLength(2) + expect(rows.filter((row) => row.body.kind === 'message')).toHaveLength(2) + expect(await f.rig.turns()).toHaveLength(1) + expect(JSON.stringify(rows)).not.toContain('signature') + const context = (await f.rig.turns())[0]?.contextUsage + expect(context?.window?.tokens).toBe(256000) + expect(context?.used?.kind === 'estimate' && contextTokensFromUsage(context.used.usage)).toBe( + 34622 + ) + }) + + it('uses the last of nine model calls instead of the aggregate turn usage', async () => { + const f = await openAcpFixtureRig() + await f.feed(await readAcpFixture('s5-plan-approved')) + const usage = (await f.rig.turns())[0]?.contextUsage?.used + expect(usage?.kind === 'estimate' && contextTokensFromUsage(usage.usage)).toBe(39190) + }) + + it('adopts missing history into a partial journal after a restart without duplicating partial text', async () => { + const f = await openAcpFixtureRig() + const frames = await readAcpFixture('s4-resume') + const live = frames.filter((frame) => frame.process === 'A') + const firstText = live.findIndex( + (frame) => + frame.message.params && JSON.stringify(frame.message.params).includes('agent_message_chunk') + ) + await f.feed(live.slice(0, firstText + 1)) + const partial = (await f.rig.rows()).find((row) => row.body.kind === 'message') + expect(partial).toBeDefined() + f.restart() + const replay = frames.filter((frame) => frame.process === 'B') + const next = replay.findIndex((frame) => frame.message.method === 'session/prompt') + await f.feed(replay.slice(0, next)) + f.finishLoad() + const rows = await f.rig.rows() + const messages = rows.filter( + (row) => row.body.kind === 'message' && row.body.role === 'assistant' + ) + expect(messages).toHaveLength(2) + expect(messages[0]?.itemId).toBe(partial?.itemId) + expect(messages.map((row) => messageText(row.body))).toEqual([ + "I'll read `notes.txt` and report the second line.", + 'The second line of `notes.txt` is `bananas`.' + ]) + expect(rows.filter((row) => row.body.kind === 'tool-call')).toHaveLength(2) + expect((await f.rig.turns())[0]?.outcome).toBe('success') + }) + + it('re-derives replay adoption after adoption itself was interrupted', async () => { + const f = await openAcpFixtureRig() + const replay = (await readAcpFixture('s4-resume')).filter((frame) => frame.process === 'B') + const next = replay.findIndex((frame) => frame.message.method === 'session/prompt') + await f.feed(replay.slice(0, 4)) + const original = (await f.rig.rows()).find( + (row) => row.body.kind === 'message' && row.body.role === 'assistant' + ) + f.restart() + await f.feed(replay.slice(0, next)) + f.finishLoad() + const rows = await f.rig.rows() + expect( + rows.filter((row) => row.body.kind === 'message' && row.body.role === 'user') + ).toHaveLength(1) + const assistants = rows.filter( + (row) => row.body.kind === 'message' && row.body.role === 'assistant' + ) + expect(assistants).toHaveLength(2) + expect(assistants[0]?.itemId).toBe(original?.itemId) + expect((await f.rig.turns())[0]?.outcome).toBe('success') + }) + + it.each([undefined, null])( + 'accepts an empty plan request and replies with the valid approval shape (%s)', + async (planContent) => { + const f = await openAcpFixtureRig() + for (const method of ['_x.ai/exit_plan_mode', 'x.ai/exit_plan_mode']) { + const presentation = f + .lane() + .request( + method, + { sessionId: 'session-1', toolCallId: 'plan', planContent }, + 1 + ).presentation + expect(presentation?.body).toMatchObject({ + kind: 'approval', + subject: { kind: 'plan', text: 'The agent exited plan mode without writing a plan.' } + }) + expect(presentation?.reply({ kind: 'option', optionId: 'approved' })).toEqual({ + outcome: 'approved' + }) + } + } + ) + + it('degrades tool snapshots under forty large concurrent tools without throwing or losing later updates', () => { + const lane = new AcpTimelineTranslator({ + sessionId: 'session-1', + journalItems: () => [], + dialect: GROK_ACP_DIALECT + }) + const big = 'x"\\'.repeat(12000) + for (let index = 0; index < 40; index += 1) { + expect(() => + lane.notification( + 'session/update', + { + sessionId: 'session-1', + update: { + sessionUpdate: 'tool_call', + toolCallId: `t${index}`, + title: 'Write', + status: 'in_progress', + rawInput: { content: big }, + rawOutput: big + } + }, + index + ) + ).not.toThrow() + } + for (let index = 0; index < 40; index += 1) { + expect( + lane.notification( + 'session/update', + { + sessionId: 'session-1', + update: { + sessionUpdate: 'tool_call_update', + toolCallId: `t${index}`, + status: 'completed' + } + }, + 50 + index + ) + ).toMatchObject([{ type: 'item.close', body: { state: 'completed' } }]) + } + }) + + it('ignores stray replay and both extension method aliases by default', async () => { + const f = await openAcpFixtureRig() + for (const method of ['_x.ai/session_notification', 'x.ai/session_notification']) { + expect( + f.lane().notification( + method, + { + sessionId: 'session-1', + update: { sessionUpdate: 'response_completed', signature: 'opaque' } + }, + 1000 + ) + ).toEqual([]) + } + expect(f.lane().notification('_unknown/extension', { sessionId: 'session-1' }, 1000)).toEqual( + [] + ) + expect( + f.lane().notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { isReplay: true }, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Old' } } + }, + 1000 + ) + ).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts index 1756521d965a..16fe309d7516 100644 --- a/src/main/acp/acp-timeline-requests.ts +++ b/src/main/acp/acp-timeline-requests.ts @@ -1,5 +1,10 @@ +import { z } from 'zod' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpDialect, AcpRequestPresentation } from './acp-dialects/acp-dialect' +import { acpJournalTurnKey } from './acp-journal-turns' +import type { AcpToolTimeline } from './acp-tool-timeline' import { AcpRpcError } from './acp-errors' -import type { AcpRequestPresentation } from './acp-dialects/acp-dialect' import { RequestPermissionRequestSchema } from './generated/acp-protocol.generated' export const pendingAcpResolution = { @@ -37,3 +42,60 @@ export function acpPermissionPresentation(params: unknown): AcpRequestPresentati } } } + +const requestSessionSchema = z.object({ sessionId: z.string() }) +const requestToolSchema = z.object({ + toolCallId: z.string().optional(), + toolCall: z.object({ toolCallId: z.string() }).optional() +}) + +export function translateAcpRequest( + method: string, + params: unknown, + id: string | number, + options: { + sessionId: string + journalItems(): readonly AgentJournalRenderItem[] + dialect: AcpDialect + tools: AcpToolTimeline + } +): { events: ProviderTimelineEvent[]; presentation?: AcpRequestPresentation } { + const session = requestSessionSchema.safeParse(params) + if (!session.success || session.data.sessionId !== options.sessionId) { + throw new AcpRpcError(-32602, 'ACP request belongs to an unknown session') + } + const presentation = + method === 'session/request_permission' + ? acpPermissionPresentation(params) + : options.dialect.request?.(method, params) + const tool = requestToolSchema.safeParse(params) + const callId = tool.success ? (tool.data.toolCall?.toolCallId ?? tool.data.toolCallId) : undefined + const storedTool = callId + ? options + .journalItems() + .find((row) => row.body.kind === 'tool-call' && row.body.callId === callId) + : undefined + const turnItemId = + storedTool?.turnScope?.kind === 'turn' ? storedTool.turnScope.turnItemId : undefined + const storedTurn = options.journalItems().find((row) => row.itemId === turnItemId) + const turn = callId + ? (options.tools.turn(callId) ?? (storedTurn && acpJournalTurnKey(storedTurn))) + : undefined + const join = { thread: options.sessionId, ...(turn === undefined ? {} : { turn }) } + if (!presentation) { + return { + events: [{ type: 'provider.frame', frameKind: `request:${method}`, payload: params, join }] + } + } + return { + presentation, + events: [ + { + type: 'request.open', + request: `${method}:${JSON.stringify(id)}`, + body: presentation.body, + join + } + ] + } +} diff --git a/src/main/acp/acp-timeline-routing.test.ts b/src/main/acp/acp-timeline-routing.test.ts new file mode 100644 index 000000000000..d5c12540c7bc --- /dev/null +++ b/src/main/acp/acp-timeline-routing.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + messageText +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' + +const chunk = (promptId: string, text: string) => ({ + sessionId: 'session-1', + _meta: { promptId }, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } +}) +const done = (promptId: string, stopReason = 'end_turn') => ({ + sessionId: 'session-1', + update: { sessionUpdate: 'turn_completed', prompt_id: promptId, stop_reason: stopReason } +}) +afterEach(closeProviderTimelineRigs) + +describe('ACP exact prompt identity', () => { + it.each(['task-completed-2', 'subagent-completed-2', 'notifications-9', 'future-wake'])( + 'opens %s independently of a queued user prompt', + async (wake) => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + expect(prompt.events).toEqual([]) + f.apply(lane.notification('session/update', chunk(wake, 'background'), 1001)) + f.apply(lane.notification('_x.ai/session_notification', done(wake), 1002)) + f.apply(lane.notification('session/update', chunk(prompt.promptId, 'user reply'), 1003)) + f.apply(lane.notification('_x.ai/session_notification', done(prompt.promptId), 1004)) + f.apply(lane.promptResult('c1', { stopReason: 'end_turn' }, 1005)) + const rows = await f.rig.rows() + const messages = rows.filter((row) => row.body.kind === 'message') + expect(messages.map((row) => messageText(row.body))).toEqual(['background', 'user reply']) + expect(messages[0]?.turnScope).not.toEqual(messages[1]?.turnScope) + expect((await f.rig.turns()).map((turn) => turn.outcome)).toEqual(['success', 'success']) + } + ) + + it('keeps a streaming background turn open when the user sends and scopes its permission to the tool', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + f.apply(lane.notification('session/update', chunk('background', 'first'), 1000)) + f.apply( + lane.notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: 'background' }, + update: { + sessionUpdate: 'tool_call', + toolCallId: 'bg-tool', + title: 'Write', + status: 'pending' + } + }, + 1001 + ) + ) + const prompt = lane.openPrompt('c1', 1002) + f.apply(prompt.events) + const request = lane.request( + 'session/request_permission', + { + sessionId: 'session-1', + toolCall: { toolCallId: 'bg-tool', title: 'Write' }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }] + }, + 0 + ) + expect(request.events[0]).toMatchObject({ join: { turn: 'background' } }) + f.apply(request.events) + f.apply(lane.notification('session/update', chunk('background', 'second'), 1003)) + f.apply(lane.notification('_x.ai/session_notification', done('background'), 1004)) + f.apply( + lane.notification( + '_x.ai/queue/changed', + { sessionId: 'session-1', runningPromptId: prompt.promptId }, + 1005 + ) + ) + f.apply(lane.notification('session/update', chunk(prompt.promptId, 'user reply'), 1006)) + f.apply(lane.promptResult('c1', { stopReason: 'end_turn' }, 1007)) + const rows = await f.rig.rows() + expect(rows.find((row) => row.body.kind === 'approval')?.turnScope).toEqual( + rows.find((row) => row.body.kind === 'tool-call')?.turnScope + ) + expect((await f.rig.turns()).map((turn) => turn.outcome)).toEqual(['success', 'success']) + expect( + rows.filter((row) => row.body.kind === 'message').map((row) => messageText(row.body)) + ).toEqual(['first', 'second', 'user reply']) + }) + + it.each(['error', 'rate_limit'])( + 'reports %s before an RPC rejection and accepts the next prompt', + async (stopReason) => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('session/update', chunk(prompt.promptId, 'partial'), 1001)) + f.apply( + lane.notification('_x.ai/session_notification', done(prompt.promptId, stopReason), 1002) + ) + f.apply(lane.promptFailed('c1', new Error('Provider rejected prompt'), 1003)) + const next = lane.openPrompt('c2', 1004) + f.apply(next.events) + f.apply(lane.notification('session/update', chunk(next.promptId, 'next'), 1005)) + expect(lane.promptResult('c1', { stopReason: 'end_turn' }, 1006)).toEqual([]) + f.apply(lane.promptResult('c2', { stopReason: 'end_turn' }, 1007)) + expect((await f.rig.turns()).map((turn) => turn.outcome)).toEqual(['failure', 'success']) + } + ) + + it('settles an RPC-only failure even if the provider never streamed a frame', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + lane.openPrompt('c1', 1000) + f.apply(lane.promptFailed('c1', { code: -32603, message: 'Rejected' }, 1001)) + expect((await f.rig.turns())[0]).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(() => lane.openPrompt('c2', 1002)).not.toThrow() + }) +}) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index fc764b0797eb..0867dcf40c12 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -1,82 +1,70 @@ import { z } from 'zod' -import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentJournalRenderItem +} from '../../shared/agent-session-journal-types' +import { BoundedMap } from '../../shared/bounded-map' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' -import { acpNotificationEnvelopeSchema, acpResponseUsage } from './acp-context-usage' +import { acpNotificationEnvelopeSchema } from './acp-context-usage' import { GENERIC_ACP_DIALECT, type AcpDialect, - type AcpDialectNotification, type AcpRequestPresentation } from './acp-dialects/acp-dialect' +import { acpJournalTurnIsSettled, acpJournalTurnHasUser } from './acp-journal-turns' +import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' import { AcpReplayUserMessages } from './acp-replay-user-messages' -import { AcpRpcError } from './acp-errors' import type { AcpSessionEvent } from './acp-session-runtime' -import { acpPermissionPresentation } from './acp-timeline-requests' +import { translateAcpRequest } from './acp-timeline-requests' +export { acpTurnEnd } from './acp-prompt-turns' import { acpSessionUpdate } from './acp-session-update' import { AcpToolTimeline } from './acp-tool-timeline' +import { AcpTurnMessages } from './acp-turn-messages' import { SessionNotificationSchema, type PromptResponse } from './generated/acp-protocol.generated' const requestSessionSchema = z.object({ sessionId: z.string() }) - -export function acpTurnEnd( - turn: string, - stopReason: string, - at: number, - durationMs?: number -): ProviderTimelineEvent { - return { - type: 'turn.end', - turn, - at, - state: stopReason === 'cancelled' ? 'interrupted' : 'completed', - ...(stopReason === 'end_turn' - ? { outcome: 'success' as const } - : stopReason === 'cancelled' - ? { outcome: 'cancellation' as const } - : ['refusal', 'max_tokens', 'max_turn_requests'].includes(stopReason) - ? { outcome: 'failure' as const } - : {}), - ...(durationMs === undefined ? {} : { durationMs }) - } +type LoadReplay = { + adopt: boolean + turn?: string + serial: number + users: AcpReplayUserMessages + pendingUser?: AgentJournalMessageItem } - -type PromptTurn = { turn: string; providerTurn?: string; durationMs?: number } -type LoadReplay = { adopt: boolean; turn?: string; serial: number; users: AcpReplayUserMessages } - export type AcpTimelineTranslatorOptions = { sessionId: string - /** The host supplies the committed journal after draining earlier writes, before session/load. */ + /** Committed journal after the host drains earlier writes before session/load. */ journalItems(): readonly AgentJournalRenderItem[] dialect?: AcpDialect } -/** Each method consumes one provider frame once; the lane retries returned grammar events. */ +/** Consumes each frame once; the host retries the returned grammar events. */ export class AcpTimelineTranslator { private readonly dialect: AcpDialect + private readonly prompts: AcpPromptTurns private readonly tools = new AcpToolTimeline() - private readonly aliases = new Map() - private prompt?: PromptTurn + private readonly messages = new AcpTurnMessages() + private readonly started = new BoundedMap({ maxEntries: 128 }) private replay?: LoadReplay + private window?: { tokens: number; capturedAt: number } + private activeTurn?: string constructor(private readonly options: AcpTimelineTranslatorOptions) { this.dialect = options.dialect ?? GENERIC_ACP_DIALECT + this.prompts = new AcpPromptTurns( + options.sessionId, + this.dialect.injectedPromptIdentity === true + ) } - openPrompt(clientMessageId: string, at: number): ProviderTimelineEvent[] { - if (this.prompt || this.replay) { + /** The host injects promptId as session/prompt._meta.promptId (and requestId). */ + openPrompt( + clientMessageId: string, + at: number + ): { promptId: string; events: ProviderTimelineEvent[] } { + if (this.replay) { throw new Error('ACP prompt overlaps a prompt or load') } - const turn = `prompt:${clientMessageId}` - this.prompt = { turn } - return [ - { - type: 'input.accepted', - clientMessageId, - requestedAt: at, - join: { thread: this.options.sessionId, turn } - }, - { type: 'turn.open', turn, at } - ] + return this.prompts.open(clientMessageId, at) } promptResult( @@ -84,38 +72,46 @@ export class AcpTimelineTranslator { result: PromptResponse, at: number ): ProviderTimelineEvent[] { - const turn = `prompt:${clientMessageId}` - const usage = - this.dialect.promptUsage?.(result, at) ?? - (result.usage ? acpResponseUsage(result.usage, at) : undefined) - const events: ProviderTimelineEvent[] = usage - ? [{ type: 'context.usage', usage, join: { thread: this.options.sessionId, turn } }] - : [] - if (!['end_turn', 'cancelled'].includes(result.stopReason)) { - events.push({ - type: 'provider.frame', - frameKind: `prompt:${result.stopReason}`, - payload: result, - join: { thread: this.options.sessionId, turn } - }) - } - events.push( - acpTurnEnd( - turn, - result.stopReason, - at, - this.prompt?.turn === turn ? this.prompt.durationMs : undefined - ) - ) - this.tools.end(turn) - if (this.prompt?.turn === turn) { - this.prompt = undefined + return this.finishPrompt(clientMessageId, result.stopReason, at) + } + + promptFailed(clientMessageId: string, _error: unknown, at: number): ProviderTimelineEvent[] { + return this.finishPrompt(clientMessageId, 'error', at) + } + + private finishPrompt( + clientMessageId: string, + stopReason: string, + at: number + ): ProviderTimelineEvent[] { + const prompt = this.prompts.current + if (prompt?.clientMessageId !== clientMessageId) { + return [] } + const events = this.prompts.start(prompt.turn, at) + events.push(acpTurnEnd(prompt.turn, stopReason, at, prompt.durationMs)) + this.end(prompt.turn) + this.prompts.current = undefined return events } + contextModels(models: unknown, at: number): ProviderTimelineEvent[] { + const tokens = this.dialect.contextWindow?.(models) + if (tokens === undefined) { + return [] + } + this.window = { tokens, capturedAt: at } + return [ + { + type: 'context.usage', + usage: { window: this.window }, + join: { thread: this.options.sessionId } + } + ] + } + beginLoad(): void { - if (this.prompt || this.replay) { + if (this.prompts.current || this.replay) { throw new Error('ACP load overlaps a prompt or load') } this.replay = { @@ -131,8 +127,7 @@ export class AcpTimelineTranslator { if (!turn) { return [] } - this.tools.end(turn) - // Generic ACP history has no stop verdict; load completion must not invent success. + this.end(turn) return [{ type: 'turn.end', turn, at, state: 'completed' }] } @@ -145,24 +140,57 @@ export class AcpTimelineTranslator { } notification(method: string, params: unknown, at: number): ProviderTimelineEvent[] { - const envelope = acpNotificationEnvelopeSchema.safeParse(params) const session = requestSessionSchema.safeParse(params) if (session.success && session.data.sessionId !== this.options.sessionId) { return [] } - const extension = this.dialect.notification?.(method, params, at) + const interpreted = this.dialect.notification?.(method, params, at) + if (interpreted?.disposition === 'ignore') { + return [] + } + const extension = interpreted + if (method !== 'session/update' && !extension) { + return [] + } + const envelope = acpNotificationEnvelopeSchema.safeParse(params) const markedReplay = (envelope.success && envelope.data._meta?.isReplay === true) || extension?.replay === true if (markedReplay && !this.replay) { - throw new Error('ACP replay requires beginLoad journal decision') + return [] } const isReplay = extension?.replay ?? (markedReplay || this.replay !== undefined) - if (isReplay && this.replay && !this.replay.adopt) { + const providerTurn = extension?.turn + if ( + isReplay && + this.replay && + (providerTurn + ? acpJournalTurnIsSettled(this.options.journalItems(), providerTurn) + : !this.replay.adopt && !this.dialect.injectedPromptIdentity) + ) { + this.replay.pendingUser = undefined return [] } const standard = method === 'session/update' ? SessionNotificationSchema.safeParse(params) : undefined const events: ProviderTimelineEvent[] = [] + const replayUser = + isReplay && this.replay && standard?.success + ? this.replay.users.observe(standard.data.update) + : undefined + if ( + isReplay && + this.replay && + this.dialect.injectedPromptIdentity && + replayUser?.body && + !providerTurn + ) { + this.replay.pendingUser = replayUser.body + return [] + } + if (replayUser?.startsMessage && this.replay?.turn && !providerTurn) { + events.push({ type: 'turn.end', turn: this.replay.turn, at, state: 'completed' }) + this.end(this.replay.turn) + } const substantive = standard?.success && [ @@ -173,64 +201,80 @@ export class AcpTimelineTranslator { 'tool_call_update', 'plan' ].includes(standard.data.update.sessionUpdate) - const replayUser = - isReplay && this.replay?.adopt && standard?.success - ? this.replay.users.observe(standard.data.update) - : undefined - if (replayUser?.startsMessage && this.replay?.turn) { - events.push({ type: 'turn.end', turn: this.replay.turn, at, state: 'completed' }) - this.tools.end(this.replay.turn) - this.replay.turn = undefined + let turn = + providerTurn ?? + (isReplay + ? this.replay?.turn + : this.prompts.current?.opened + ? this.prompts.current.turn + : this.activeTurn) + if (!turn && isReplay && this.replay?.adopt && substantive) { + turn = `replay:${this.replay.serial++}` } - const turn = this.routeTurn( - extension, - events, - at, - substantive || extension?.end !== undefined, - isReplay - ) - const join = { - join: { thread: this.options.sessionId, ...(turn === undefined ? {} : { turn }) } + if (turn && (substantive || extension?.end || extension?.started)) { + events.push(...this.start(turn, extension?.at ?? at)) + if (isReplay && this.replay) { + this.replay.turn = turn + if (this.replay.pendingUser) { + const existing = acpJournalTurnHasUser(this.options.journalItems(), turn) + if (!existing) { + events.push({ + type: 'item.update', + item: `replay-user:${turn}:`, + body: this.replay.pendingUser, + join: { thread: this.options.sessionId, turn } + }) + } + this.replay.pendingUser = undefined + } + } } + const join = { thread: this.options.sessionId, ...(turn === undefined ? {} : { turn }) } if (extension?.usage) { - events.push({ type: 'context.usage', usage: extension.usage, ...join }) + this.window = extension.usage.window ?? this.window + events.push({ + type: 'context.usage', + usage: { ...extension.usage, ...(this.window ? { window: this.window } : {}) }, + join + }) } if (extension?.end && turn) { - // Client prompts settle on their own response; extension completions own autonomous turns. - if (this.prompt?.turn === turn) { - this.prompt.durationMs = extension.end.durationMs + if ( + this.prompts.current?.turn === turn && + ['end_turn', 'cancelled'].includes(extension.end.stopReason) + ) { + this.prompts.current.durationMs = extension.end.durationMs } else { - if (!['end_turn', 'cancelled'].includes(extension.end.stopReason)) { - events.push({ - type: 'provider.frame', - frameKind: `turn:${extension.end.stopReason}`, - payload: params, - ...join - }) - } events.push(acpTurnEnd(turn, extension.end.stopReason, at, extension.end.durationMs)) - this.tools.end(turn) - if (this.replay?.turn === turn) { - this.replay.turn = undefined + this.end(turn) + if (this.prompts.current?.turn === turn) { + this.prompts.current = undefined } } return events } if (standard?.success) { + const messageKey = + turn && this.dialect.injectedPromptIdentity + ? this.messages.key(turn, standard.data.update) + : undefined return [ ...events, ...acpSessionUpdate( standard.data, turn, at, - isReplay && (this.replay?.adopt ?? false), + isReplay, this.tools, this.dialect, - replayUser?.body + replayUser?.body, + messageKey ) ] } - events.push({ type: 'provider.frame', frameKind: method, payload: params, ...join }) + if (method === 'session/update') { + events.push({ type: 'provider.frame', frameKind: method, payload: params, join }) + } return events } @@ -238,79 +282,36 @@ export class AcpTimelineTranslator { method: string, params: unknown, id: string | number - ): { - events: ProviderTimelineEvent[] - presentation?: AcpRequestPresentation - } { - const session = requestSessionSchema.safeParse(params) - if (!session.success || session.data.sessionId !== this.options.sessionId) { - throw new AcpRpcError(-32602, 'ACP request belongs to an unknown session') - } - const presentation = - method === 'session/request_permission' - ? acpPermissionPresentation(params) - : this.dialect.request?.(method, params) - const join = { thread: this.options.sessionId } - if (!presentation) { - return { - events: [{ type: 'provider.frame', frameKind: `request:${method}`, payload: params, join }] - } - } - return { - presentation, - events: [ - { - type: 'request.open', - request: `${method}:${JSON.stringify(id)}`, - body: presentation.body, - join - } - ] - } + ): { events: ProviderTimelineEvent[]; presentation?: AcpRequestPresentation } { + return translateAcpRequest(method, params, id, { + ...this.options, + dialect: this.dialect, + tools: this.tools + }) } - private alias(providerTurn: string, turn: string): void { - if (this.aliases.size >= 128) { - this.aliases.delete(this.aliases.keys().next().value ?? '') + private start(turn: string, at: number): ProviderTimelineEvent[] { + if (turn === this.prompts.current?.turn) { + const events = this.prompts.start(turn, at) + this.activeTurn = turn + return events } - this.aliases.set(providerTurn, turn) + if (this.started.has(turn)) { + return [] + } + this.started.set(turn, true) + this.activeTurn = turn + return [{ type: 'turn.open', turn, at }] } - private routeTurn( - extension: AcpDialectNotification | undefined, - events: ProviderTimelineEvent[], - at: number, - substantive: boolean, - isReplay: boolean - ): string | undefined { - const providerTurn = extension?.turn - const known = providerTurn ? this.aliases.get(providerTurn) : undefined - if (known) { - return known - } - if (isReplay && this.replay?.adopt && substantive) { - if (!this.replay.turn) { - this.replay.turn = providerTurn ?? `replay:${this.replay.serial++}` - events.push({ type: 'turn.open', turn: this.replay.turn, at: extension?.at ?? at }) - } - if (providerTurn) { - this.alias(providerTurn, this.replay.turn) - } - return this.replay.turn - } - if (extension?.agentInitiated && providerTurn && substantive) { - this.alias(providerTurn, providerTurn) - events.push({ type: 'turn.open', turn: providerTurn, at: extension.at ?? at }) - return providerTurn + private end(turn: string): void { + this.tools.end(turn) + this.messages.end(turn) + if (this.activeTurn === turn) { + this.activeTurn = undefined } - if (providerTurn && this.prompt && !this.prompt.providerTurn) { - this.prompt.providerTurn = providerTurn - this.alias(providerTurn, this.prompt.turn) + if (this.replay?.turn === turn) { + this.replay.turn = undefined } - return ( - (providerTurn ? (this.aliases.get(providerTurn) ?? providerTurn) : undefined) ?? - this.replay?.turn ?? - this.prompt?.turn - ) } } diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts index b1c72d6d063e..5cde7b29e0b0 100644 --- a/src/main/acp/acp-tool-timeline.ts +++ b/src/main/acp/acp-tool-timeline.ts @@ -150,6 +150,10 @@ export class AcpToolTimeline { return events } + turn(toolCallId: string): string | undefined { + return this.tools.get(toolCallId)?.turn + } + end(turn: string): void { for (const [key, snapshot] of this.tools) { if (snapshot.turn === turn) { @@ -165,6 +169,13 @@ export class AcpToolTimeline { private remember(key: string, snapshot: ToolSnapshot): void { const previous = this.tools.get(key) + if (this.size(key, snapshot) > 1024 * 1024) { + if (previous) { + this.bytes -= this.size(key, previous) + this.tools.delete(key) + } + return + } let bytes = this.bytes - (previous ? this.size(key, previous) : 0) + this.size(key, snapshot) let count = this.tools.size + (previous ? 0 : 1) const evicted: string[] = [] @@ -178,8 +189,15 @@ export class AcpToolTimeline { bytes -= this.size(candidate, stored) } } - if (count > 128 || bytes > 1024 * 1024) { - throw new Error('ACP tool snapshot budget exceeded') + for (const [candidate, stored] of this.tools) { + if (count <= 128 && bytes <= 1024 * 1024) { + break + } + if (candidate !== key && !evicted.includes(candidate)) { + evicted.push(candidate) + count -= 1 + bytes -= this.size(candidate, stored) + } } for (const candidate of evicted) { this.tools.delete(candidate) diff --git a/src/main/acp/acp-turn-messages.ts b/src/main/acp/acp-turn-messages.ts new file mode 100644 index 000000000000..e7b8a7e49bc9 --- /dev/null +++ b/src/main/acp/acp-turn-messages.ts @@ -0,0 +1,35 @@ +import { BoundedMap } from '../../shared/bounded-map' +import type { SessionUpdate } from './generated/acp-protocol.generated' + +type MessagePosition = { ordinal: number; channel?: string } + +/** Stable turn-relative message positions also identify coalesced load snapshots. */ +export class AcpTurnMessages { + private readonly turns = new BoundedMap({ maxEntries: 128 }) + + key(turn: string, update: SessionUpdate): string | undefined { + const state = this.turns.get(turn) ?? { ordinal: 0 } + this.turns.set(turn, state) + if ( + update.sessionUpdate !== 'agent_message_chunk' && + update.sessionUpdate !== 'agent_thought_chunk' + ) { + if (['tool_call', 'tool_call_update', 'plan'].includes(update.sessionUpdate)) { + state.channel = undefined + } + return undefined + } + if (update.messageId) { + return `message:${update.messageId}` + } + if (state.channel !== update.sessionUpdate) { + state.ordinal += 1 + state.channel = update.sessionUpdate + } + return `turn-message:${JSON.stringify([turn, state.ordinal])}` + } + + end(turn: string): void { + this.turns.delete(turn) + } +} diff --git a/src/main/acp/fixtures/s1-basic.jsonl b/src/main/acp/fixtures/s1-basic.jsonl index e15324dcc61a..b36e8ce2f1dd 100644 --- a/src/main/acp/fixtures/s1-basic.jsonl +++ b/src/main/acp/fixtures/s1-basic.jsonl @@ -1,11 +1,12 @@ -{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" list"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"list_dir","rawInput":{"target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"other","title":"List `/workspace/file-1`","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"read_file","rawInput":{"target_file":"/workspace/file-2"},"_meta":{"x.ai/tool":{"name":"read_file","kind":"read"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"read","title":"Read `/workspace/file-2`","locations":[{"path":"/workspace/file-2"}],"rawInput":{"variant":"ReadFile","target_file":"/workspace/file-2"},"_meta":{"x.ai/tool":{"name":"read_file","kind":"read"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","content":[{"type":"content","content":{"type":"text","text":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n"}}],"rawOutput":{"type":"ReadFile","FileContent":{"content":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","content_concise":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","absolute_path":"/workspace/file-2","offset":null,"raw_output":"# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","total_lines":4}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-3\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":68922,"outputTokens":195,"cachedReadTokens":35840,"cacheCreationTokens":0},"elapsed_ms":5289}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:3"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" list"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"list_dir","rawInput":{"target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"other","title":"List `/workspace/file-1`","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"read_file","rawInput":{"target_file":"/workspace/file-2"},"_meta":{"x.ai/tool":{"name":"read_file","kind":"read"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"read","title":"Read `/workspace/file-2`","locations":[{"path":"/workspace/file-2"}],"rawInput":{"variant":"ReadFile","target_file":"/workspace/file-2"},"_meta":{"x.ai/tool":{"name":"read_file","kind":"read"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","content":[{"type":"content","content":{"type":"text","text":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n"}}],"rawOutput":{"type":"ReadFile","FileContent":{"content":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","content_concise":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","absolute_path":"/workspace/file-2","offset":null,"raw_output":"# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","total_lines":4}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-3\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":446,"output_tokens":74,"cache_read_input_tokens":34176,"cache_creation_input_tokens":0,"reasoning_tokens":24}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:3","stop_reason":"end_turn","usage":{"inputTokens":68922,"outputTokens":195,"cachedReadTokens":35840,"cacheCreationTokens":0},"elapsed_ms":5289}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":68922,"outputTokens":195,"cachedReadTokens":35840,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s1-full-notifications.jsonl b/src/main/acp/fixtures/s1-full-notifications.jsonl new file mode 100644 index 000000000000..de1e19fd50a0 --- /dev/null +++ b/src/main/acp/fixtures/s1-full-notifications.jsonl @@ -0,0 +1,142 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false},"clientInfo":{"name":"orca-acp-capture","title":"Orca ACP capture harness","version":"0.0.1"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true},"sessionCapabilities":{"list":{},"resume":{},"close":{}},"auth":{},"_meta":{"x.ai/fs_notify":true,"x.ai/hooks":{"blockingEvents":["pre_tool_use","stop","subagent_stop"],"decisions":["deny","block"],"stopSignals":["continue","stopReason","additionalContext"]},"x.ai/capabilities":{"toolOverrides":{"x_keyword_search":true,"x_semantic_search":true,"x_user_search":false,"x_thread_fetch":false}}}},"authMethods":[{"id":"cached_token","name":"cached_token","description":"Cached token from ~/.grok/auth.json"},{"id":"grok.com","name":"Grok","description":"Sign in with Grok"}],"_meta":{"grokShell":true,"defaultAuthMethodId":"cached_token","x.ai/mcp/sdk":true,"x.ai/pluginDirs":true,"currentWorkingDirectory":"Fixture value","agentVersion":"1.0.46","agentId":"","agentInstanceId":"","modelState":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"Fixture value","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]},"mcpServers":[],"mcpApps":false,"metadata":null,"availableCommands":[],"cancelRewind":true,"sessionRecap":true,"feedbackTraceOffer":false,"voiceMode":true}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"Fixture value","mcpServers":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"auth","sessionId":null}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"resolve_workspace","sessionId":null}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"folder_trust","sessionId":null}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"plugin_registry","sessionId":null}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"mcp_merge","sessionId":null}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"persistence_init","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/mcp/servers_updated","params":{"mcpServers":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"spawn_session_actor","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"agent_build","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"Fixture value","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"Fixture value","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/settings/update","params":{"show_resolved_model":false,"sharing_enabled":false,"privacy_notice_rollout":true,"privacy_banner_reshow_days":0,"session_picker_grouped":null,"tips":["Use @ to attach files like @src/main.rs.","Use @! for hidden or ignored files: @!.github/workflows.","Press Ctrl+O to toggle auto-approve mode.","Use Shift+Tab to cycle between modes like Plan mode.","Run /compact [context] when chat gets long.","Press Ctrl+B to background a running terminal command.","Start Grok in a fresh worktree with `-w`; add `-r ` to resume an existing session there.","Use Ctrl+Enter to interject messages. Or just Enter to queue messages.","Run /dashboard (or Ctrl+\\) to see and manage all your agents in one place.","Try out workflows using /workflows."],"slash_command_tags":{"learn":"new","memory":"new"},"announcements":[{"id":"grok-4.7","message":"Select 'Grok 4.7' under /model.","severity":"info","title":"Grok 4.7 is here!","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null},{"id":"Fixture value","message":"Ask Grok to /learn from your past traces and tune Grok Build to how you work.","severity":"info","title":"Fixture value","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null}],"campaigns":[{"id":"grok-4.7-launch","models":{"default":"grok-4.7"}}],"gate_message":null,"gate_url":null,"gate_label":null,"allow_access":true,"consent_gate":null,"auto_permission_mode_enabled":null,"prompt_suggestions_enabled":null,"permission_mode":null,"group_tool_verbs":null,"collapsed_edit_blocks":null,"subscription_watch_interval_secs":60,"dock_enabled":true,"terminal_theme_enabled":null,"subagent_model_inheritance_enabled":true}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/announcements/update","params":{"gen":1791057313,"announcements":[{"id":"grok-4.7","message":"Select 'Grok 4.7' under /model.","severity":"info","title":"Grok 4.7 is here!","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null},{"id":"Fixture value","message":"Ask Grok to /learn from your past traces and tune Grok Build to how you work.","severity":"info","title":"Fixture value","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/mcp_initialized","params":{"sessionId":"session-1","mcpToolCount":0,"elapsedMs":0}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/settings/update","params":{"show_resolved_model":false,"sharing_enabled":false,"privacy_notice_rollout":true,"privacy_banner_reshow_days":0,"session_picker_grouped":null,"tips":["Use @ to attach files like @src/main.rs.","Use @! for hidden or ignored files: @!.github/workflows.","Press Ctrl+O to toggle auto-approve mode.","Use Shift+Tab to cycle between modes like Plan mode.","Run /compact [context] when chat gets long.","Press Ctrl+B to background a running terminal command.","Start Grok in a fresh worktree with `-w`; add `-r ` to resume an existing session there.","Use Ctrl+Enter to interject messages. Or just Enter to queue messages.","Run /dashboard (or Ctrl+\\) to see and manage all your agents in one place.","Try out workflows using /workflows."],"slash_command_tags":{"learn":"new","memory":"new"},"announcements":[{"id":"grok-4.7","message":"Select 'Grok 4.7' under /model.","severity":"info","title":"Grok 4.7 is here!","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null},{"id":"Fixture value","message":"Ask Grok to /learn from your past traces and tune Grok Build to how you work.","severity":"info","title":"Fixture value","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null}],"campaigns":[{"id":"grok-4.7-launch","models":{"default":"grok-4.7"}}],"gate_message":null,"gate_url":null,"gate_label":null,"allow_access":true,"consent_gate":null,"auto_permission_mode_enabled":null,"prompt_suggestions_enabled":null,"permission_mode":null,"group_tool_verbs":null,"collapsed_edit_blocks":null,"subscription_watch_interval_secs":60,"dock_enabled":true,"terminal_theme_enabled":null,"subagent_model_inheritance_enabled":true}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/announcements/update","params":{"gen":1791057314,"announcements":[{"id":"grok-4.7","message":"Select 'Grok 4.7' under /model.","severity":"info","title":"Grok 4.7 is here!","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null},{"id":"Fixture value","message":"Ask Grok to /learn from your past traces and tune Grok Build to how you work.","severity":"info","title":"Fixture value","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"model_switch","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1833,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-0","agentTimestampMs":1791057313672,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1833,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-1","agentTimestampMs":1791057313680,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"model_changed","model_id":"grok-4.7","reasoning_effort":"high"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"git_discovery","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"finalize_response","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"tool_overrides","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":2,"result":{"sessionId":"session-1","models":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"Fixture value","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]},"configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"grok-4.7","options":[{"value":"grok-4.7","name":"Grok 4.7"},{"value":"grok-4.7-build-fast","name":"Grok 4.7 Fast"},{"value":"grok-4.6","name":"Grok 4.6"},{"value":"grok-4.5","name":"Grok 4.5"}]},{"id":"reasoning_effort","name":"Reasoning Effort","category":"thought_level","type":"select","currentValue":"high","options":[{"value":"xhigh","name":"Extra High","description":"Maximum reasoning for the hardest tasks."},{"value":"high","name":"High","description":"Thorough reasoning and quality. Recommended."},{"value":"medium","name":"Medium","description":"Strong quality with a faster turnaround."},{"value":"low","name":"Low","description":"Fastest responses. Best for simple tasks."}]}],"_meta":{"currentWorkingDirectory":"Fixture value","codebaseIndexed":[],"isGitRepo":false,"gitRoot":null,"showNonGitWarning":false,"feedbackEnabled":true,"x.ai/sessionConfig":{"options":[{"id":"grok-4.7","category":"model","label":"Grok 4.7","selected":true},{"id":"grok-4.7-build-fast","category":"model","label":"Grok 4.7 Fast","selected":false},{"id":"grok-4.6","category":"model","label":"Grok 4.6","selected":false},{"id":"grok-4.5","category":"model","label":"Grok 4.5","selected":false},{"id":"xhigh","category":"mode","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","selected":false},{"id":"high","category":"mode","label":"High","description":"Thorough reasoning and quality. Recommended.","selected":true},{"id":"medium","category":"mode","label":"Medium","description":"Strong quality with a faster turnaround.","selected":false},{"id":"low","category":"mode","label":"Low","description":"Fastest responses. Best for simple tasks.","selected":false}]},"x.ai/sessionDetail":{"sessionId":"session-1","kind":"build","cwd":"Fixture value","currentModelId":"grok-4.7"},"x.ai/memoryMode":"v2"}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"list the files in this folder and read README.md"}],"_meta":{"promptId":"prompt:p1:3"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"response_ready","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"session_start","runs":[{"name":"global/orca-status:session_start[0].hooks[0]","status":{"status":"success","elapsed_ms":9}},{"name":"global/settings:session_start[0].hooks[0]","status":{"status":"success","elapsed_ms":14}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-2","agentTimestampMs":1791057313704}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"session-1","title":null,"cwd":"Fixture value","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1791057313707,"origin":{"kind":"local"}}],"removed":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[{"id":"prompt:p1:3","version":0,"kind":"prompt","text":"list the files in this folder and read README.md","position":0}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[],"runningPromptId":"prompt:p1:3","runningText":"list the files in this folder and read README.md","runningKind":"prompt"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_run_started","event_name":"user_prompt_submit","prompt_id":"prompt:p1:3","count":2},"_meta":{"agentTimestampMs":1791057313720}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"user_prompt_submit","prompt_id":"prompt:p1:3","runs":[{"name":"global/orca-status:user_prompt_submit[0].hooks[0]","status":{"status":"success","elapsed_ms":8}},{"name":"global/settings:user_prompt_submit[0].hooks[0]","status":{"status":"success","elapsed_ms":11}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-3","agentTimestampMs":1791057313739}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"Fixture value","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/announcements/update","params":{"gen":1791057315,"announcements":[{"id":"grok-4.7","message":"Select 'Grok 4.7' under /model.","severity":"info","title":"Grok 4.7 is here!","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null},{"id":"Fixture value","message":"Ask Grok to /learn from your past traces and tune Grok Build to how you work.","severity":"info","title":"Fixture value","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-6","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":1}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" list"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-7","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":2}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-8","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":3}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" folder"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-9","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":4}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" contents"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-10","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":5}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" and"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-11","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":6}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" read"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-12","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":7}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-13","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":8}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"README"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-14","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":9}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".md"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-15","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":10}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`."}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-16","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":11}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","tool_index":0,"name":"list_dir"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":0,"arguments_delta":"Fixture value"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":"Fixture value","result":{"result":{"reloaded":1}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-17","agentTimestampMs":1791057316633,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":"Fixture value","result":{"result":{"reloaded":1}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":"Fixture value","result":{"result":{"reloaded":1}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-18","agentTimestampMs":1791057316655,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-19","agentTimestampMs":1791057316666,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","tool_index":1,"name":"read_file"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":1,"arguments_delta":"Fixture value"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":34429,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-20","agentTimestampMs":1791057316961,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":32636,"output_tokens":121,"cache_read_input_tokens":1664,"cache_creation_input_tokens":0,"reasoning_tokens":28}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_run_started","event_name":"pre_tool_use","tool_name":"list_dir","count":2},"_meta":{"agentTimestampMs":1791057316962}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","title":"list_dir","rawInput":{"target_directory":"Fixture value"},"_meta":{"x.ai/tool":{"version":1,"name":"list_dir","kind":"list","namespace":"grok_build","label":"List Files","read_only":true}}},"_meta":{"totalTokens":34429,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-21","agentTimestampMs":1791057316962,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"ToolCall","updateParams":{"toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","title":"list_dir","kind":"Other","status":"Pending"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"pre_tool_use","tool_name":"list_dir","runs":[{"name":"global/orca-status:pre_tool_use[0].hooks[0]","status":{"status":"success","elapsed_ms":11}},{"name":"global/settings:pre_tool_use[1].hooks[0]","status":{"status":"success","elapsed_ms":12}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-22","agentTimestampMs":1791057316986}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","kind":"permission"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","kind":"other","title":"Fixture value","locations":[{"path":"Fixture value"}],"rawInput":{"variant":"ListDir","target_directory":"Fixture value"},"_meta":{"x.ai/tool":{"version":1,"name":"list_dir","kind":"list","namespace":"grok_build","label":"List Files","read_only":true,"input":{"directory":"Fixture value"}}}},"_meta":{"totalTokens":34429,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-23","agentTimestampMs":1791057316986,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","status":null}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_run_started","event_name":"pre_tool_use","tool_name":"read_file","count":2},"_meta":{"agentTimestampMs":1791057316988}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","title":"read_file","rawInput":{"target_file":"Fixture value"},"_meta":{"x.ai/tool":{"version":1,"name":"read_file","kind":"read","namespace":"grok_build","label":"Read","read_only":true}}},"_meta":{"totalTokens":34429,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-24","agentTimestampMs":1791057316988,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"ToolCall","updateParams":{"toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","title":"read_file","kind":"Other","status":"Pending"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"pre_tool_use","tool_name":"read_file","runs":[{"name":"global/orca-status:pre_tool_use[0].hooks[0]","status":{"status":"success","elapsed_ms":8}},{"name":"global/settings:pre_tool_use[1].hooks[0]","status":{"status":"success","elapsed_ms":12}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-25","agentTimestampMs":1791057317008}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","kind":"permission"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","kind":"read","title":"Fixture value","locations":[{"path":"Fixture value"}],"rawInput":{"variant":"ReadFile","target_file":"Fixture value"},"_meta":{"x.ai/tool":{"version":1,"name":"read_file","kind":"read","namespace":"grok_build","label":"Read","read_only":true,"input":{"path":"Fixture value"}}}},"_meta":{"totalTokens":34429,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-26","agentTimestampMs":1791057317008,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","status":null}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_run_started","event_name":"post_tool_use","tool_name":"read_file","count":2},"_meta":{"agentTimestampMs":1791057317012}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"post_tool_use","tool_name":"read_file","runs":[{"name":"global/orca-status:post_tool_use[0].hooks[0]","status":{"status":"success","elapsed_ms":8}},{"name":"global/settings:post_tool_use[0].hooks[0]","status":{"status":"success","elapsed_ms":11}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-28","agentTimestampMs":1791057317032}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_run_started","event_name":"post_tool_use","tool_name":"list_dir","count":2},"_meta":{"agentTimestampMs":1791057317035}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n"}}],"rawOutput":{"type":"ReadFile","FileContent":{"content":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","content_concise":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","absolute_path":"Fixture value","offset":null,"raw_output":"# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","total_lines":4}}},"_meta":{"totalTokens":34429,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-27","agentTimestampMs":1791057317032,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","status":"Completed"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"post_tool_use","tool_name":"list_dir","runs":[{"name":"global/orca-status:post_tool_use[0].hooks[0]","status":{"status":"success","elapsed_ms":8}},{"name":"global/settings:post_tool_use[0].hooks[0]","status":{"status":"success","elapsed_ms":19}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-30","agentTimestampMs":1791057317063}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","status":"completed","rawOutput":{"type":"ListDir","Content":{"content":"Fixture value","absolute_root_path":"Fixture value"}}},"_meta":{"totalTokens":34449,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-29","agentTimestampMs":1791057317063,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","status":"Completed"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":"Fixture value","result":{"result":{"reloaded":1}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-33","agentTimestampMs":1791057317622,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_summary_generated","session_summary":"List files and read README.md"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"List files and read README.md"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"This"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-34","agentTimestampMs":1791057318881,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":1}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" folder"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-35","agentTimestampMs":1791057318881,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":2}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" has"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-36","agentTimestampMs":1791057318882,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":3}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" three"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-37","agentTimestampMs":1791057318882,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":4}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" files"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-38","agentTimestampMs":1791057318882,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":5}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":\n\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-39","agentTimestampMs":1791057318882,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":6}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"-"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-40","agentTimestampMs":1791057318882,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":7}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-41","agentTimestampMs":1791057318882,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":8}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"math"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-42","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":9}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".js"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-43","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":10}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-44","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":11}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"-"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-45","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":12}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-46","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":13}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"notes"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-47","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":14}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".txt"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-48","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":15}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-49","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":16}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"-"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-50","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":17}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-51","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":18}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"README"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-52","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":19}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".md"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-53","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":20}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`\n\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-54","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":21}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-55","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":22}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"README"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-56","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":23}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".md"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-57","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":24}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-58","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":25}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" says"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-59","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":26}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":\n\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-60","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":27}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-61","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":28}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"markdown"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-62","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":29}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-63","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":30}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"#"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-64","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":31}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" Scratch"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-65","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":32}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" project"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-66","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":33}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" "}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-67","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":34}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"1"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-68","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":35}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-69","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":36}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"A"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-70","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":37}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tiny"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-71","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":38}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" throw"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-72","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":39}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"away"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-73","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":40}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" project"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-74","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":41}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" used"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-75","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":42}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" to"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-76","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":43}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" record"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-77","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":44}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" G"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-78","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":45}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"rok"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-79","agentTimestampMs":1791057318890,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":46}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" ACP"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-80","agentTimestampMs":1791057318890,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":47}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" traffic"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-81","agentTimestampMs":1791057318890,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":48}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-82","agentTimestampMs":1791057318890,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":49}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-83","agentTimestampMs":1791057318890,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":50}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":34705,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-84","agentTimestampMs":1791057318943,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":446,"output_tokens":74,"cache_read_input_tokens":34176,"cache_creation_input_tokens":0,"reasoning_tokens":24}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_run_started","event_name":"stop","prompt_id":"prompt:p1:3","count":2},"_meta":{"agentTimestampMs":1791057318944}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"stop","prompt_id":"prompt:p1:3","runs":[{"name":"global/orca-status:stop[0].hooks[0]","status":{"status":"success","elapsed_ms":8}},{"name":"global/settings:stop[0].hooks[0]","status":{"status":"success","elapsed_ms":11}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-87","agentTimestampMs":1791057318964}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:3","stop_reason":"end_turn","usage":{"inputTokens":68922,"outputTokens":195,"totalTokens":69117,"cachedReadTokens":35840,"cacheCreationTokens":0,"reasoningTokens":52,"modelCalls":2,"apiDurationMs":5045,"costUsdTicks":289863600,"modelUsage":{"grok-4.7-build":{"inputTokens":68922,"outputTokens":195,"totalTokens":69117,"cachedReadTokens":35840,"cacheCreationTokens":0,"reasoningTokens":52,"modelCalls":2,"apiDurationMs":5045,"costUsdTicks":289863600}},"numTurns":2},"elapsed_ms":5289},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-88","agentTimestampMs":1791057319003}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/prompt_complete","params":{"sessionId":"session-1","promptId":"prompt:p1:3","stopReason":"end_turn","agentResult":null}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"session-1","title":null,"cwd":"Fixture value","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":true,"activity":"idle","resident":true,"lastChangeUnixMs":1791057319035,"origin":{"kind":"local"}}],"removed":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"sessionId":"session-1","requestId":"prompt:p1:3","promptId":"prompt:p1:3","totalTokens":34705,"modelId":"grok-4.7","inputTokens":34622,"outputTokens":74,"cachedReadTokens":34176,"reasoningTokens":24,"usage":{"inputTokens":68922,"outputTokens":195,"totalTokens":69117,"cachedReadTokens":35840,"cacheCreationTokens":0,"reasoningTokens":52,"modelCalls":2,"apiDurationMs":5045,"costUsdTicks":289863600,"modelUsage":{"grok-4.7-build":{"inputTokens":68922,"outputTokens":195,"totalTokens":69117,"cachedReadTokens":35840,"cacheCreationTokens":0,"reasoningTokens":52,"modelCalls":2,"apiDurationMs":5045,"costUsdTicks":289863600}},"numTurns":2}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"last_turn_summary","summary":"Three files; README is a tiny Grok ACP scratch project","prompt_id":"prompt:p1:3"},"_meta":{"agentTimestampMs":1791057320779}}}} diff --git a/src/main/acp/fixtures/s2-permission.jsonl b/src/main/acp/fixtures/s2-permission.jsonl index fe5198b52458..66bb39db3367 100644 --- a/src/main/acp/fixtures/s2-permission.jsonl +++ b/src/main/acp/fixtures/s2-permission.jsonl @@ -1,20 +1,20 @@ -{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" create"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"write","rawInput":{"file_path":"/workspace/file-1","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"edit","title":"Write `/workspace/file-1`","content":[{"type":"diff","path":"/workspace/file-1","oldText":"","newText":"hello from grok\n"}],"locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"Write","file_path":"/workspace/file-1","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:3"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" create"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"write","rawInput":{"file_path":"/workspace/file-1","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"edit","title":"Write `/workspace/file-1`","content":[{"type":"diff","path":"/workspace/file-1","oldText":"","newText":"hello from grok\n"}],"locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"Write","file_path":"/workspace/file-1","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call-1","kind":"edit","title":"Write `/workspace/file-1` \u2014 hook 'plugin/capture-force-ask/hooks:pre_tool_use[0].hooks[0]' asks: capture harness forces a permission prompt","rawInput":{"variant":"Write","file_path":"/workspace/file-1","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"options":[{"optionId":"allow-edits-session","name":"Yes, allow all edits during this session","kind":"allow_always"},{"optionId":"allow-once","name":"Yes","kind":"allow_once"},{"optionId":"reject-once","name":"No, and tell Grok what to do differently","kind":"reject_once"}]}}} {"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"diff","path":"/workspace/file-1","oldText":"","newText":"hello from grok\n"}],"rawOutput":{"type":"SearchReplace","EditsApplied":{"old_string":"","new_string":"hello from grok\n","tool_output_for_prompt":"The file /workspace/file-1 has been created.","tool_output_for_prompt_concise":"The file /workspace/file-1 has been created.","absolute_path":"/workspace/file-1","edits":{"details":[{"old_string":"","old_line":1,"new_string":"hello from grok\n","new_line":1,"context_before":"","context_after":"","line_prefix":""}]}}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":69120,"outputTokens":260,"cachedReadTokens":36096,"cacheCreationTokens":0},"elapsed_ms":8673}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"diff","path":"/workspace/file-1","oldText":"","newText":"hello from grok\n"}],"rawOutput":{"type":"SearchReplace","EditsApplied":{"old_string":"","new_string":"hello from grok\n","tool_output_for_prompt":"The file /workspace/file-1 has been created.","tool_output_for_prompt_concise":"The file /workspace/file-1 has been created.","absolute_path":"/workspace/file-1","edits":{"details":[{"old_string":"","old_line":1,"new_string":"hello from grok\n","new_line":1,"context_before":"","context_after":"","line_prefix":""}]}}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:3","stop_reason":"end_turn","usage":{"inputTokens":69120,"outputTokens":260,"cachedReadTokens":36096,"cacheCreationTokens":0},"elapsed_ms":8673}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":69120,"outputTokens":260,"cachedReadTokens":36096,"cacheCreationTokens":0}}}}} -{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":5,"method":"session/prompt","params":{"sessionId":"session-2","prompt":[{"type":"text","text":"Fixture prompt"}]}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" create"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"write","rawInput":{"file_path":"/workspace/file-2","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"edit","title":"Write `/workspace/file-2`","content":[{"type":"diff","path":"/workspace/file-2","oldText":"","newText":"hello from grok\n"}],"locations":[{"path":"/workspace/file-2"}],"rawInput":{"variant":"Write","file_path":"/workspace/file-2","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":5,"method":"session/prompt","params":{"sessionId":"session-2","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:5"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:5","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" create"}},"_meta":{"promptId":"prompt:p1:5","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"write","rawInput":{"file_path":"/workspace/file-2","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"prompt:p1:5","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"edit","title":"Write `/workspace/file-2`","content":[{"type":"diff","path":"/workspace/file-2","oldText":"","newText":"hello from grok\n"}],"locations":[{"path":"/workspace/file-2"}],"rawInput":{"variant":"Write","file_path":"/workspace/file-2","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"prompt:p1:5","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"session-2","toolCall":{"toolCallId":"call-2","kind":"edit","title":"Write `/workspace/file-2` \u2014 hook 'plugin/capture-force-ask/hooks:pre_tool_use[0].hooks[0]' asks: capture harness forces a permission prompt","rawInput":{"variant":"Write","file_path":"/workspace/file-2","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"options":[{"optionId":"allow-edits-session","name":"Yes, allow all edits during this session","kind":"allow_always"},{"optionId":"allow-once","name":"Yes","kind":"allow_once"},{"optionId":"reject-once","name":"No, and tell Grok what to do differently","kind":"reject_once"}]}}} {"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":1,"result":{"outcome":{"outcome":"selected","optionId":"reject-once"}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"failed","content":[{"type":"content","content":{"type":"text","text":"User rejected the execution for tool `write`"}}]},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-2","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-2","stop_reason":"cancelled","usage":{"inputTokens":34448,"outputTokens":163,"cachedReadTokens":1664,"cacheCreationTokens":0},"elapsed_ms":4475}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"failed","content":[{"type":"content","content":{"type":"text","text":"User rejected the execution for tool `write`"}}]},"_meta":{"promptId":"prompt:p1:5","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-2","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:5","stop_reason":"cancelled","usage":{"inputTokens":34448,"outputTokens":163,"cachedReadTokens":1664,"cacheCreationTokens":0},"elapsed_ms":4475}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":5,"result":{"stopReason":"cancelled","_meta":{"usage":{"inputTokens":34448,"outputTokens":163,"cachedReadTokens":1664,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s3-cancel.jsonl b/src/main/acp/fixtures/s3-cancel.jsonl index a8828e5ef760..72c56ee2edf3 100644 --- a/src/main/acp/fixtures/s3-cancel.jsonl +++ b/src/main/acp/fixtures/s3-cancel.jsonl @@ -1,13 +1,13 @@ -{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" run"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"run_terminal_command","rawInput":{"command":"sleep 20; echo finished > cancel-marker.txt","description":"Sleep 20s then write cancel marker","block_until_ms":35000},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"execute","title":"Execute `sleep 20; echo finished > cancel-marker.txt`","content":[{"type":"content","content":{"type":"text","text":"Sleep 20s then write cancel marker"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 20; echo finished > cancel-marker.txt","description":"Sleep 20s then write cancel marker","is_background":false,"block_until_ms":35000},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:3"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" run"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"run_terminal_command","rawInput":{"command":"sleep 20; echo finished > cancel-marker.txt","description":"Sleep 20s then write cancel marker","block_until_ms":35000},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"execute","title":"Execute `sleep 20; echo finished > cancel-marker.txt`","content":[{"type":"content","content":{"type":"text","text":"Sleep 20s then write cancel marker"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 20; echo finished > cancel-marker.txt","description":"Sleep 20s then write cancel marker","is_background":false,"block_until_ms":35000},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":""}}],"rawOutput":{"type":"Bash","output":[],"output_for_prompt":"","exit_code":0,"command":"sleep 20; echo finished > cancel-marker.txt","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/workspace/file-1","output_file":"","total_bytes":0}}}}} {"direction":"out","process":"p1","message":{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"cancelled","usage":{"inputTokens":34344,"outputTokens":318,"cachedReadTokens":1664,"cacheCreationTokens":0},"elapsed_ms":7773}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:3","stop_reason":"cancelled","usage":{"inputTokens":34344,"outputTokens":318,"cachedReadTokens":1664,"cacheCreationTokens":0},"elapsed_ms":7773}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled","_meta":{"usage":{"inputTokens":34344,"outputTokens":318,"cachedReadTokens":1664,"cacheCreationTokens":0}}}}} -{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ok"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-2","stop_reason":"end_turn","usage":{"inputTokens":34876,"outputTokens":189,"cachedReadTokens":34304,"cacheCreationTokens":0},"elapsed_ms":3373}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:4"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ok"}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:4","stop_reason":"end_turn","usage":{"inputTokens":34876,"outputTokens":189,"cachedReadTokens":34304,"cacheCreationTokens":0},"elapsed_ms":3373}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":34876,"outputTokens":189,"cachedReadTokens":34304,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s4-resume.jsonl b/src/main/acp/fixtures/s4-resume.jsonl index 479e13236172..93adbc92a2f9 100644 --- a/src/main/acp/fixtures/s4-resume.jsonl +++ b/src/main/acp/fixtures/s4-resume.jsonl @@ -1,23 +1,23 @@ -{"direction":"out","process":"A","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} -{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" read"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"grep","rawInput":{"pattern":".","glob":"notes.txt"},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"search","title":".","locations":[],"rawInput":{"variant":"Grep","pattern":".","path":null,"glob":"notes.txt","-i":false,"type":null,"multiline":false},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"list_dir","rawInput":{"target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"other","title":"List `/workspace/file-1`","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-2\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"found 3 matches"}}],"rawOutput":{"type":"GrepSearch","stdout":[60,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,32,119,111,114,107,115,112,97,99,101,95,112,97,116,104,61,34,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,34,62,10,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,47,110,111,116,101,115,46,116,120,116,58,101,120,97,109,112,108,101,10,60,47,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,62],"stderr":[],"exit_code":0,"match_count":3,"file_matches":[{"path":"/workspace/file-3","matches":[{"line_number":1,"content":"apples"},{"line_number":2,"content":"bananas"},{"line_number":3,"content":"cherries"}]}]}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":68864,"outputTokens":121,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5043}}}} +{"direction":"out","process":"A","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:A:3"}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" read"}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"grep","rawInput":{"pattern":".","glob":"notes.txt"},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"search","title":".","locations":[],"rawInput":{"variant":"Grep","pattern":".","path":null,"glob":"notes.txt","-i":false,"type":null,"multiline":false},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"list_dir","rawInput":{"target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"other","title":"List `/workspace/file-1`","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-2\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"found 3 matches"}}],"rawOutput":{"type":"GrepSearch","stdout":[60,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,32,119,111,114,107,115,112,97,99,101,95,112,97,116,104,61,34,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,34,62,10,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,47,110,111,116,101,115,46,116,120,116,58,101,120,97,109,112,108,101,10,60,47,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,62],"stderr":[],"exit_code":0,"match_count":3,"file_matches":[{"path":"/workspace/file-3","matches":[{"line_number":1,"content":"apples"},{"line_number":2,"content":"bananas"},{"line_number":3,"content":"cherries"}]}]}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:A:3","stop_reason":"end_turn","usage":{"inputTokens":68864,"outputTokens":121,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5043}}}} {"direction":"in","process":"A","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":68864,"outputTokens":121,"cachedReadTokens":35968,"cacheCreationTokens":0}}}}} {"direction":"out","process":"B","message":{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":"session-1"}}} {"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"Read notes.txt and tell me its second line."},"_meta":{"promptIndex":0}},"_meta":{"isReplay":true}}}} -{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll read `notes.txt` and report the second line."}},"_meta":{"promptId":"turn-1","turnStartMs":1000,"isReplay":true}}}} -{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"List `/workspace/file-1`","status":"completed","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-2\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000,"isReplay":true}}}} -{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":".","kind":"search","status":"completed","content":[{"type":"content","content":{"type":"text","text":"found 3 matches"}}],"rawInput":{"variant":"Grep","pattern":".","path":null,"glob":"notes.txt","-i":false,"type":null,"multiline":false},"rawOutput":{"type":"GrepSearch","stdout":[60,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,32,119,111,114,107,115,112,97,99,101,95,112,97,116,104,61,34,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,34,62,10,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,47,110,111,116,101,115,46,116,120,116,58,101,120,97,109,112,108,101,10,60,47,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,62],"stderr":[],"exit_code":0,"match_count":3,"file_matches":[{"path":"/workspace/file-3","matches":[{"line_number":1,"content":"apples"},{"line_number":2,"content":"bananas"},{"line_number":3,"content":"cherries"}]}]},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000,"isReplay":true}}}} -{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The second line of `notes.txt` is `bananas`."}},"_meta":{"promptId":"turn-1","turnStartMs":1000,"isReplay":true}}}} -{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"_x.ai/session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":68864,"outputTokens":121,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5043},"_meta":{"isReplay":true}}}} -{"direction":"out","process":"B","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} -{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} -{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" second"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} -{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-2","stop_reason":"end_turn","usage":{"inputTokens":34621,"outputTokens":36,"cachedReadTokens":34432,"cacheCreationTokens":0},"elapsed_ms":1829}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll read `notes.txt` and report the second line."}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"List `/workspace/file-1`","status":"completed","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-2\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":".","kind":"search","status":"completed","content":[{"type":"content","content":{"type":"text","text":"found 3 matches"}}],"rawInput":{"variant":"Grep","pattern":".","path":null,"glob":"notes.txt","-i":false,"type":null,"multiline":false},"rawOutput":{"type":"GrepSearch","stdout":[60,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,32,119,111,114,107,115,112,97,99,101,95,112,97,116,104,61,34,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,34,62,10,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,47,110,111,116,101,115,46,116,120,116,58,101,120,97,109,112,108,101,10,60,47,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,62],"stderr":[],"exit_code":0,"match_count":3,"file_matches":[{"path":"/workspace/file-3","matches":[{"line_number":1,"content":"apples"},{"line_number":2,"content":"bananas"},{"line_number":3,"content":"cherries"}]}]},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The second line of `notes.txt` is `bananas`."}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"_x.ai/session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:A:3","stop_reason":"end_turn","usage":{"inputTokens":68864,"outputTokens":121,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5043},"_meta":{"isReplay":true}}}} +{"direction":"out","process":"B","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:B:3"}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}},"_meta":{"promptId":"prompt:B:3","turnStartMs":1000}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" second"}},"_meta":{"promptId":"prompt:B:3","turnStartMs":1000}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:B:3","stop_reason":"end_turn","usage":{"inputTokens":34621,"outputTokens":36,"cachedReadTokens":34432,"cacheCreationTokens":0},"elapsed_ms":1829}}}} {"direction":"in","process":"B","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":34621,"outputTokens":36,"cachedReadTokens":34432,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s5-plan-approved.jsonl b/src/main/acp/fixtures/s5-plan-approved.jsonl index 43b2e30a194e..29e87decdb06 100644 --- a/src/main/acp/fixtures/s5-plan-approved.jsonl +++ b/src/main/acp/fixtures/s5-plan-approved.jsonl @@ -1,15 +1,16 @@ -{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" look"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"ask_user_question","rawInput":{"questions":[{"question":"Which test approach do you want for the subtract function?","options":[{"label":"node:test (Recommended)","description":"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."},{"label":"Plain assert script","description":"A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework."},{"label":"Add a package.json and a test script","description":"Add package.json with a test script that runs the same Node built-in tests, so npm test works."}]}]},"_meta":{"x.ai/tool":{"name":"ask_user_question","kind":"ask_user"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"other","title":"Ask: Which test approach do you want for the subtract function?","locations":[],"rawInput":{"variant":"AskUserQuestion","questions":[{"question":"Which test approach do you want for the subtract function?","options":[{"label":"node:test (Recommended)","description":"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."},{"label":"Plain assert script","description":"A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework."},{"label":"Add a package.json and a test script","description":"Add package.json with a test script that runs the same Node built-in tests, so npm test works."}],"multiSelect":null}]},"_meta":{"x.ai/tool":{"name":"ask_user_question","kind":"ask_user"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:4"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" look"}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"ask_user_question","rawInput":{"questions":[{"question":"Which test approach do you want for the subtract function?","options":[{"label":"node:test (Recommended)","description":"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."},{"label":"Plain assert script","description":"A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework."},{"label":"Add a package.json and a test script","description":"Add package.json with a test script that runs the same Node built-in tests, so npm test works."}]}]},"_meta":{"x.ai/tool":{"name":"ask_user_question","kind":"ask_user"}}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"other","title":"Ask: Which test approach do you want for the subtract function?","locations":[],"rawInput":{"variant":"AskUserQuestion","questions":[{"question":"Which test approach do you want for the subtract function?","options":[{"label":"node:test (Recommended)","description":"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."},{"label":"Plain assert script","description":"A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework."},{"label":"Add a package.json and a test script","description":"Add package.json with a test script that runs the same Node built-in tests, so npm test works."}],"multiSelect":null}]},"_meta":{"x.ai/tool":{"name":"ask_user_question","kind":"ask_user"}}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"_x.ai/ask_user_question","params":{"sessionId":"session-1","toolCallId":"call-1","questions":[{"question":"Which test approach do you want for the subtract function?","options":[{"label":"node:test (Recommended)","description":"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."},{"label":"Plain assert script","description":"A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework."},{"label":"Add a package.json and a test script","description":"Add package.json with a test script that runs the same Node built-in tests, so npm test works."}],"multiSelect":null}],"mode":"default"}}} {"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":"accepted","answers":{"Which test approach do you want for the subtract function?":"node:test (Recommended)"}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"User has answered your questions: \"Which test approach do you want for the subtract function?\"=\"node:test (Recommended)\". You can now continue with the user's answers in mind."}}],"rawOutput":{"type":"AskUserQuestion","UserAnswered":{"message":"User has answered your questions: \"Which test approach do you want for the subtract function?\"=\"node:test (Recommended)\". You can now continue with the user's answers in mind."}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"exit_plan_mode","rawInput":{},"_meta":{"x.ai/tool":{"name":"exit_plan_mode","kind":"exit_plan"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"other","title":"Plan: Exit","locations":[],"rawInput":{"variant":"ExitPlanMode"},"_meta":{"x.ai/tool":{"name":"exit_plan_mode","kind":"exit_plan"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"User has answered your questions: \"Which test approach do you want for the subtract function?\"=\"node:test (Recommended)\". You can now continue with the user's answers in mind."}}],"rawOutput":{"type":"AskUserQuestion","UserAnswered":{"message":"User has answered your questions: \"Which test approach do you want for the subtract function?\"=\"node:test (Recommended)\". You can now continue with the user's answers in mind."}}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"exit_plan_mode","rawInput":{},"_meta":{"x.ai/tool":{"name":"exit_plan_mode","kind":"exit_plan"}}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"other","title":"Plan: Exit","locations":[],"rawInput":{"variant":"ExitPlanMode"},"_meta":{"x.ai/tool":{"name":"exit_plan_mode","kind":"exit_plan"}}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":1,"method":"_x.ai/exit_plan_mode","params":{"sessionId":"session-1","toolCallId":"call-2","planContent":"# Plan\n\nUpdate the example and run its tests."}}} {"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":1,"result":{"outcome":"approved"}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","title":"Plan mode exited","content":[{"type":"content","content":{"type":"text","text":"Plan file: /workspace/file-1"}}],"rawOutput":{"type":"ExitPlanMode","PlanReady":{"message":"Your plan has been approved. You can now start coding.","plan_content":"# Add subtract and a node:test\n\n## Problem\n\n`math.js` only exports `add(a, b)`. There is no `subtract`, and nothing in this folder can check that the math functions return the right values.\n\n## User-facing change\n\nCallers can import `subtract` from `math.js` and get `a - b`. A new test file checks that behavior and can be run with Node's built-in test runner.\n\n## Approach\n\nStay inside this folder. No new dependencies and no `package.json`. You chose `node:test`.\n\n1. In `math.js`, add `subtract(a, b)` next to `add`, using the same style: a named export that returns `a - b`.\n2. Add `math.test.js` that imports `subtract` and uses `node:test` plus `node:assert/strict`. Cover a positive difference, a negative difference, and zero.\n3. Run `node --test math.test.js` from this folder and confirm the tests pass.\n\n## Out of scope\n\n- Changing `add`\n- Editing `README.md` or `notes.txt`\n- Adding a package manager, test script, or extra files\n","plan_file_path":"/workspace/file-1"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":331949,"outputTokens":3858,"cachedReadTokens":294144,"cacheCreationTokens":0},"elapsed_ms":64864}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","title":"Plan mode exited","content":[{"type":"content","content":{"type":"text","text":"Plan file: /workspace/file-1"}}],"rawOutput":{"type":"ExitPlanMode","PlanReady":{"message":"Your plan has been approved. You can now start coding.","plan_content":"# Add subtract and a node:test\n\n## Problem\n\n`math.js` only exports `add(a, b)`. There is no `subtract`, and nothing in this folder can check that the math functions return the right values.\n\n## User-facing change\n\nCallers can import `subtract` from `math.js` and get `a - b`. A new test file checks that behavior and can be run with Node's built-in test runner.\n\n## Approach\n\nStay inside this folder. No new dependencies and no `package.json`. You chose `node:test`.\n\n1. In `math.js`, add `subtract(a, b)` next to `add`, using the same style: a named export that returns `a - b`.\n2. Add `math.test.js` that imports `subtract` and uses `node:test` plus `node:assert/strict`. Cover a positive difference, a negative difference, and zero.\n3. Run `node --test math.test.js` from this folder and confirm the tests pass.\n\n## Out of scope\n\n- Changing `add`\n- Editing `README.md` or `notes.txt`\n- Adding a package manager, test script, or extra files\n","plan_file_path":"/workspace/file-1"}}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":278,"output_tokens":255,"cache_read_input_tokens":38912,"cache_creation_input_tokens":0,"reasoning_tokens":138}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:4","stop_reason":"end_turn","usage":{"inputTokens":331949,"outputTokens":3858,"cachedReadTokens":294144,"cacheCreationTokens":0},"elapsed_ms":64864}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":331949,"outputTokens":3858,"cachedReadTokens":294144,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s6-background.jsonl b/src/main/acp/fixtures/s6-background.jsonl index e63527fd9d23..927b68df55e2 100644 --- a/src/main/acp/fixtures/s6-background.jsonl +++ b/src/main/acp/fixtures/s6-background.jsonl @@ -1,10 +1,10 @@ -{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" start"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"run_terminal_command","rawInput":{"command":"sleep 15; echo bg-done > bg-marker.txt","description":"Sleep then write bg-marker.txt","block_until_ms":0},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"execute","title":"Execute `sleep 15; echo bg-done > bg-marker.txt`","content":[{"type":"content","content":{"type":"text","text":"Sleep then write bg-marker.txt"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 15; echo bg-done > bg-marker.txt","description":"Sleep then write bg-marker.txt","is_background":false,"block_until_ms":0},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","title":"[bg] sleep 15; echo bg-done > bg-marker.txt (01a10366)","content":[{"type":"content","content":{"type":"text","text":"Background task 01a10366-1e7f-7191-bcd1-fac24585851f started"}}],"rawOutput":{"type":"BackgroundTaskStarted","task_id":"01a10366-1e7f-7191-bcd1-fac24585851f","task_type":"bash","output_file":"/workspace/file-1","status":"running","command":"sleep 15; echo bg-done > bg-marker.txt","summary":"Background task 01a10366-1e7f-7191-bcd1-fac24585851f started","retrieval_hint":"Use get_command_or_subagent_output with task_ids=[\"01a10366-1e7f-7191-bcd1-fac24585851f\"] when you need the output."}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":69127,"outputTokens":207,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5774}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:3"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" start"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"run_terminal_command","rawInput":{"command":"sleep 15; echo bg-done > bg-marker.txt","description":"Sleep then write bg-marker.txt","block_until_ms":0},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"execute","title":"Execute `sleep 15; echo bg-done > bg-marker.txt`","content":[{"type":"content","content":{"type":"text","text":"Sleep then write bg-marker.txt"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 15; echo bg-done > bg-marker.txt","description":"Sleep then write bg-marker.txt","is_background":false,"block_until_ms":0},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","title":"[bg] sleep 15; echo bg-done > bg-marker.txt (01a10366)","content":[{"type":"content","content":{"type":"text","text":"Background task 01a10366-1e7f-7191-bcd1-fac24585851f started"}}],"rawOutput":{"type":"BackgroundTaskStarted","task_id":"01a10366-1e7f-7191-bcd1-fac24585851f","task_type":"bash","output_file":"/workspace/file-1","status":"running","command":"sleep 15; echo bg-done > bg-marker.txt","summary":"Background task 01a10366-1e7f-7191-bcd1-fac24585851f started","retrieval_hint":"Use get_command_or_subagent_output with task_ids=[\"01a10366-1e7f-7191-bcd1-fac24585851f\"] when you need the output."}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:3","stop_reason":"end_turn","usage":{"inputTokens":69127,"outputTokens":207,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5774}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":69127,"outputTokens":207,"cachedReadTokens":35968,"cacheCreationTokens":0}}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}},"_meta":{"promptId":"task-completed-2","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" background"}},"_meta":{"promptId":"task-completed-2","turnStartMs":1000}}}} From 19b4c7e24e53de4affaba3eaaf0fb76b135d8922 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:16:31 -0700 Subject: [PATCH 053/269] Detect short and Unicode home paths in encoded ACP fixtures --- src/main/acp/acp-fixture-privacy.test.ts | 42 +++++++++++++++--------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/src/main/acp/acp-fixture-privacy.test.ts b/src/main/acp/acp-fixture-privacy.test.ts index b683ec1eeb1e..e95728768881 100644 --- a/src/main/acp/acp-fixture-privacy.test.ts +++ b/src/main/acp/acp-fixture-privacy.test.ts @@ -46,9 +46,17 @@ function decodedStrings(value: unknown, depth = 0): string[] { } catch { /* Ordinary text is not JSON. */ } - for (const token of value.match(/[A-Za-z0-9+/_-]{16,}={0,2}/g) ?? []) { + for (const token of value.match(/[A-Za-z0-9+/_-]{8,}={0,2}/g) ?? []) { const decoded = Buffer.from(token, 'base64url').toString('utf8') - if (decoded !== token && /^[\x20-\x7e\r\n\t]+$/.test(decoded)) { + if ( + decoded !== token && + !decoded.includes('\uFFFD') && + [...decoded].every( + (char) => + ['\n', '\r', '\t'].includes(char) || + (char.charCodeAt(0) >= 32 && char.charCodeAt(0) !== 127) + ) + ) { texts.push(...decodedStrings(decoded, depth + 1)) } } @@ -56,20 +64,24 @@ function decodedStrings(value: unknown, depth = 0): string[] { } describe('ACP recording privacy', () => { - it.each(['/Users/private/project', '/home/private/project', 'C:\\Users\\private\\project'])( - 'detects home paths after nested JSON, URL, base64 and byte encoding: %s', - (path) => { - for (const value of [ - path, - encodeURIComponent(path), - Buffer.from(path).toString('base64'), - [...Buffer.from(path)], - JSON.stringify({ nested: JSON.stringify(path) }) - ]) { - expect(decodedStrings(value).some((text) => privateText.test(text))).toBe(true) - } + it.each([ + '/Users/u', + '/home/u', + '/home/é', + '/Users/private/project', + '/home/private/project', + 'C:\\Users\\private\\project' + ])('detects home paths after nested JSON, URL, base64 and byte encoding: %s', (path) => { + for (const value of [ + path, + encodeURIComponent(path), + Buffer.from(path).toString('base64'), + [...Buffer.from(path)], + JSON.stringify({ nested: JSON.stringify(path) }) + ]) { + expect(decodedStrings(value).some((text) => privateText.test(text))).toBe(true) } - ) + }) it('contains no personal metadata or home-directory paths in any decoded fixture', async () => { const directory = new URL('./fixtures/', import.meta.url) From 33fda9bcc36a143e4f44234a7e39e45e3598313e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:44:58 -0700 Subject: [PATCH 054/269] Use generic privacy patterns for ACP fixtures --- src/main/acp/acp-fixture-privacy.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/acp/acp-fixture-privacy.test.ts b/src/main/acp/acp-fixture-privacy.test.ts index e95728768881..dced40612d11 100644 --- a/src/main/acp/acp-fixture-privacy.test.ts +++ b/src/main/acp/acp-fixture-privacy.test.ts @@ -2,7 +2,7 @@ import { readFile, readdir } from 'node:fs/promises' import { describe, expect, it } from 'vitest' const privateText = - /\/Users\/|\/home\/|[a-z]:[\\/]+Users[\\/]|brennan|orca-qa|native-chat-other-agents|[\w.+-]+@[\w.-]+\.[a-z]{2,}|\bhostname\b|\b[\w-]+\.(?:local|lan)\b|signature|subscription_tier|skills?/i + /\/Users\/|\/home\/|[a-z]:[\\/]+Users[\\/]|[\w.+-]+@[\w.-]+\.[a-z]{2,}|\bhostname\b|\b[\w-]+\.(?:local|lan)\b|signature|subscription_tier|skills?/i function decodedStrings(value: unknown, depth = 0): string[] { if (depth > 8) { From d52d38b40ae518752b2582d6bfb19d9beacbb270 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:17:30 -0700 Subject: [PATCH 055/269] Translate Grok background work into durable task rows --- src/main/acp/acp-background-task-timeline.ts | 71 ++++++++ src/main/acp/acp-context-usage.ts | 30 ++++ src/main/acp/acp-dialects/acp-dialect.ts | 6 + .../acp/acp-dialects/grok-background-tasks.ts | 104 +++++++++++ src/main/acp/acp-dialects/grok-dialect.ts | 6 + src/main/acp/acp-session-update.ts | 7 +- .../acp/acp-timeline-background-tasks.test.ts | 170 ++++++++++++++++++ src/main/acp/acp-timeline-recordings.test.ts | 4 +- src/main/acp/acp-timeline-translator.ts | 31 ++-- .../claude-background-task-row-journal.ts | 18 +- src/shared/native-chat-background-task-row.ts | 11 ++ 11 files changed, 421 insertions(+), 37 deletions(-) create mode 100644 src/main/acp/acp-background-task-timeline.ts create mode 100644 src/main/acp/acp-dialects/grok-background-tasks.ts create mode 100644 src/main/acp/acp-timeline-background-tasks.test.ts diff --git a/src/main/acp/acp-background-task-timeline.ts b/src/main/acp/acp-background-task-timeline.ts new file mode 100644 index 000000000000..141e2f480237 --- /dev/null +++ b/src/main/acp/acp-background-task-timeline.ts @@ -0,0 +1,71 @@ +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { BoundedMap } from '../../shared/bounded-map' +import { backgroundTaskJournalBody } from '../../shared/native-chat-background-task-row' +import { + isBackgroundTaskBlock, + type NativeChatBackgroundTaskBlock +} from '../../shared/native-chat-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import type { + ProviderTimelineEvent, + ProviderTimelineJoin +} from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpBackgroundTaskUpdate } from './acp-dialects/acp-dialect' + +/** Partial provider snapshots only; the assembler owns settlement and durable placement. */ +export class AcpBackgroundTaskTimeline { + private readonly snapshots = new BoundedMap({ + maxEntries: 128, + maxBytes: 1024 * 1024, + sizeOf: (block, key) => Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(block)) + }) + + constructor(private readonly journalItems: () => readonly AgentJournalRenderItem[]) {} + + translate( + updates: AcpBackgroundTaskUpdate[], + join: ProviderTimelineJoin + ): ProviderTimelineEvent[] { + return updates.map((update) => { + const previous = this.snapshots.get(update.taskId) ?? this.persisted(update.taskId) + const block: NativeChatBackgroundTaskBlock = { + type: 'background-task', + kind: 'unknown', + label: update.taskId, + ...previous, + ...update + } + for (const key of ['label', 'summary', 'error', 'outputFile'] as const) { + const text = block[key] + if (text !== undefined) { + block[key] = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + } + } + this.snapshots.set(block.taskId, block) + return { + type: 'item.update', + item: `background-task:${block.taskId}`, + body: backgroundTaskJournalBody(block), + join + } + }) + } + + private persisted(taskId: string): NativeChatBackgroundTaskBlock | undefined { + for (const row of this.journalItems()) { + if (row.body.kind !== 'message') { + continue + } + const block = row.body.blocks.find( + (candidate) => isBackgroundTaskBlock(candidate) && candidate.taskId === taskId + ) + if (block && isBackgroundTaskBlock(block)) { + return block + } + } + return undefined + } +} diff --git a/src/main/acp/acp-context-usage.ts b/src/main/acp/acp-context-usage.ts index 000b915295bc..f3f260df741c 100644 --- a/src/main/acp/acp-context-usage.ts +++ b/src/main/acp/acp-context-usage.ts @@ -1,7 +1,37 @@ import { z } from 'zod' import type { AgentSessionContextUsage } from '../../shared/agent-session-context-usage' +import type { + ProviderTimelineEvent, + ProviderTimelineJoin +} from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpDialect } from './acp-dialects/acp-dialect' import type { UsageUpdate } from './generated/acp-protocol.generated' +export class AcpContextTimeline { + private window?: { tokens: number; capturedAt: number } + + models( + models: unknown, + at: number, + dialect: AcpDialect, + join: ProviderTimelineJoin + ): ProviderTimelineEvent[] { + const tokens = dialect.contextWindow?.(models) + return tokens === undefined ? [] : this.update({ window: { tokens, capturedAt: at } }, join) + } + + update(usage: AgentSessionContextUsage, join: ProviderTimelineJoin): ProviderTimelineEvent[] { + this.window = usage.window ?? this.window + return [ + { + type: 'context.usage', + usage: { ...usage, ...(this.window ? { window: this.window } : {}) }, + join + } + ] + } +} + export function acpWindowUsage(update: UsageUpdate, at: number): AgentSessionContextUsage { return { window: { tokens: update.size, capturedAt: at }, diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 04f9c4c3997a..4b7d15a32c93 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -1,5 +1,6 @@ import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' +import type { NativeChatBackgroundTaskBlock } from '../../../shared/native-chat-types' import type { ProviderTimelineRequestBody } from '../../native-chat/agent-session-timeline/provider-timeline-event' import type { ToolCallUpdate } from '../generated/acp-protocol.generated' @@ -8,6 +9,9 @@ export type AcpRequestPresentation = { reply(response: AgentSessionPromptResponse | null): unknown } +export type AcpBackgroundTaskUpdate = Pick & + Partial> + export type AcpDialectNotification = | { disposition: 'ignore' } | { @@ -18,12 +22,14 @@ export type AcpDialectNotification = started?: boolean end?: { stopReason: string; durationMs?: number } usage?: AgentSessionContextUsage + backgroundTasks?: AcpBackgroundTaskUpdate[] } /** Hooks interpret extensions; lifecycle and row identity stay shared. */ export type AcpDialect = { injectedPromptIdentity?: true toolName?(update: ToolCallUpdate): string | undefined + toolBackgroundTasks?(update: ToolCallUpdate): AcpBackgroundTaskUpdate[] notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined contextWindow?(models: unknown): number | undefined request?(method: string, params: unknown): AcpRequestPresentation | undefined diff --git a/src/main/acp/acp-dialects/grok-background-tasks.ts b/src/main/acp/acp-dialects/grok-background-tasks.ts new file mode 100644 index 000000000000..6813f195ec57 --- /dev/null +++ b/src/main/acp/acp-dialects/grok-background-tasks.ts @@ -0,0 +1,104 @@ +import { z } from 'zod' +import { normalizeBackgroundTaskKind } from '../../../shared/native-chat-background-task-row' +import type { NativeChatBackgroundTaskBlock } from '../../../shared/native-chat-types' +import type { ToolCallUpdate } from '../generated/acp-protocol.generated' +import type { AcpBackgroundTaskUpdate, AcpDialectNotification } from './acp-dialect' + +const taskSchema = z.object({ + task_id: z.string().min(1), + command: z.string().optional(), + description: z.string().nullish(), + monitor_description: z.string().nullish(), + display_command: z.string().nullish(), + output_file: z.string().optional(), + summary: z.string().optional(), + error: z.string().optional(), + output: z.string().optional(), + kind: z.string().optional(), + task_type: z.string().optional(), + exit_code: z.number().int().nullish(), + signal: z.union([z.string(), z.number()]).nullish(), + explicitly_killed: z.boolean().optional() +}) +const backgroundedSchema = taskSchema.extend({ + sessionUpdate: z.literal('task_backgrounded'), + tool_call_id: z.string().optional() +}) +const completedSchema = z.object({ + sessionUpdate: z.literal('task_completed'), + task_snapshot: taskSchema +}) +const startedOutputSchema = taskSchema.extend({ type: z.literal('BackgroundTaskStarted') }) +type GrokTask = z.infer + +function snapshot( + task: GrokTask, + state: NativeChatBackgroundTaskBlock['state'] +): AcpBackgroundTaskUpdate { + const kind = task.kind ?? task.task_type + const monitor = kind === 'monitor' || task.monitor_description != null + const label = task.monitor_description ?? task.description ?? task.display_command ?? task.command + return { + taskId: task.task_id, + state, + ...(monitor + ? { kind: 'monitor' as const } + : kind !== undefined || task.command !== undefined + ? { + kind: + kind === 'bash' || kind === 'shell' || task.command !== undefined + ? ('command' as const) + : normalizeBackgroundTaskKind(kind ?? 'unknown') + } + : {}), + ...(label === undefined ? {} : { label }), + ...(task.output_file === undefined ? {} : { outputFile: task.output_file }), + ...(task.summary === undefined && task.output === undefined + ? {} + : { summary: task.summary ?? task.output }), + ...(task.error === undefined ? {} : { error: task.error }) + } +} + +export function grokToolBackgroundTasks(update: ToolCallUpdate): AcpBackgroundTaskUpdate[] { + const parsed = startedOutputSchema.safeParse(update.rawOutput) + return parsed.success + ? [{ ...snapshot(parsed.data, 'working'), parentToolUseId: update.toolCallId }] + : [] +} + +export function grokBackgroundTaskNotification( + canonicalMethod: string, + params: unknown +): AcpDialectNotification | undefined { + if (!['x.ai/task_backgrounded', 'x.ai/task_completed'].includes(canonicalMethod)) { + return undefined + } + const parsed = z + .object({ update: z.union([backgroundedSchema, completedSchema]) }) + .safeParse(params) + if (!parsed.success) { + return { disposition: 'ignore' } + } + const update = parsed.data.update + if (update.sessionUpdate === 'task_backgrounded') { + return { + disposition: 'map', + backgroundTasks: [ + { + ...snapshot(update, update.monitor_description != null ? 'monitoring' : 'working'), + ...(update.tool_call_id === undefined ? {} : { parentToolUseId: update.tool_call_id }) + } + ] + } + } + const task = update.task_snapshot + const state = task.explicitly_killed + ? 'idle' + : task.signal != null || (task.exit_code != null && task.exit_code !== 0) + ? 'blocked' + : task.exit_code === 0 + ? 'done' + : 'unverifiable' + return { disposition: 'map', backgroundTasks: [snapshot(task, state)] } +} diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index 700676d6b581..51a7bf7f59cc 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { acpNotificationEnvelopeSchema } from '../acp-context-usage' import type { AcpDialect, AcpDialectNotification } from './acp-dialect' import { grokRequest } from './grok-requests' +import { grokBackgroundTaskNotification, grokToolBackgroundTasks } from './grok-background-tasks' const tokenCount = z.number().int().nonnegative() const toolMetaSchema = z.object({ 'x.ai/tool': z.object({ name: z.string().min(1) }) }) @@ -55,6 +56,10 @@ function notification( at: number ): AcpDialectNotification | undefined { const canonical = method.startsWith('_') ? method.slice(1) : method + const backgroundTask = grokBackgroundTaskNotification(canonical, params) + if (backgroundTask) { + return backgroundTask + } if (canonical === 'x.ai/models/update') { const tokens = contextWindow(params) return tokens === undefined @@ -125,6 +130,7 @@ export const GROK_ACP_DIALECT: AcpDialect = { return parsed.success ? parsed.data['x.ai/tool'].name : undefined }, request: grokRequest, + toolBackgroundTasks: grokToolBackgroundTasks, notification, contextWindow } diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index b9fd57e93736..aa891d2e100f 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -6,6 +6,7 @@ import { } from '../native-chat/agent-session-journal/journal-payload-bounds' import { acpWindowUsage } from './acp-context-usage' import type { AcpDialect } from './acp-dialects/acp-dialect' +import type { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' import type { AcpToolTimeline } from './acp-tool-timeline' import type { SessionNotification } from './generated/acp-protocol.generated' @@ -16,6 +17,7 @@ export function acpSessionUpdate( replay: boolean, tools: AcpToolTimeline, dialect: AcpDialect, + backgroundTasks: AcpBackgroundTaskTimeline, replayUserBody?: AgentJournalMessageItem, messageKey?: string ): ProviderTimelineEvent[] { @@ -70,7 +72,10 @@ export function acpSessionUpdate( : [] case 'tool_call': case 'tool_call_update': - return tools.translate(update, dialect, join.join) + return [ + ...tools.translate(update, dialect, join.join), + ...backgroundTasks.translate(dialect.toolBackgroundTasks?.(update) ?? [], join.join) + ] case 'plan': return [ { diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts new file mode 100644 index 000000000000..60c6f9565c8a --- /dev/null +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -0,0 +1,170 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row' +import { deriveNativeChatRowContent } from '../../shared/native-chat-row-content' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' + +afterEach(closeProviderTimelineRigs) + +const taskId = '01a10366-1e7f-7191-bcd1-fac24585851f' + +async function taskRows(fixture: Awaited>) { + return (await fixture.rig.rows()).flatMap((row) => { + if (row.body.kind !== 'message') { + return [] + } + const block = row.body.blocks.find(isBackgroundTaskBlock) + return block ? [{ row, block }] : [] + }) +} + +function started(id = taskId) { + return { + sessionId: 'session-1', + update: { + sessionUpdate: 'task_backgrounded', + task_id: id, + tool_call_id: 'call-1', + command: 'sleep 15', + description: 'Wait for the background command', + output_file: '/workspace/output.log' + } + } +} + +function completed(snapshot: object = { exit_code: 0 }) { + return { + sessionId: 'session-1', + update: { sessionUpdate: 'task_completed', task_snapshot: { task_id: taskId, ...snapshot } } + } +} + +describe('Grok background tasks through the shared timeline', () => { + it.each(['end', 'reset'] as const)( + 'renders the recorded task outside turn settlement and marks it unverifiable on session %s after restart', + async (boundary) => { + const fixture = await openAcpFixtureRig() + await fixture.feed(await readAcpFixture('s6-background')) + const [before] = await taskRows(fixture) + expect(before?.block).toMatchObject({ + taskId, + kind: 'command', + state: 'working', + parentToolUseId: 'call-1', + outputFile: '/workspace/file-1' + }) + expect(before?.row.turnScope?.kind).toBe('turn') + expect((await fixture.rig.turns()).map((turn) => turn.state)).toEqual([ + 'completed', + 'completed' + ]) + expect( + (await fixture.rig.rows()).find((row) => row.body.kind === 'tool-call')?.body + ).toMatchObject({ state: 'completed' }) + if (!before || before.row.body.kind !== 'message') { + throw new Error('Missing background-task row') + } + const render = deriveNativeChatRowContent(before.row.body.blocks) + expect(render.backgroundTasks).toEqual([before.block]) + expect(render.prose).toEqual([]) + + fixture.restart() + fixture.apply([ + boundary === 'end' + ? { type: 'session.ended', verdict: { state: 'unverifiable' } } + : { type: 'session.reset', namespace: 'replacement-session', generation: 'gen-3' } + ]) + const [after] = await taskRows(fixture) + expect(after?.row.itemId).toBe(before.row.itemId) + expect(after?.block.state).toBe('unverifiable') + expect(after?.row.body).toMatchObject({ + blocks: [{ type: 'text', text: backgroundTaskFallbackText(after!.block) }, after!.block] + }) + } + ) + + it.each(['x.ai/', '_x.ai/'])( + 'joins %s task notifications and the tool result into one row and keeps its origin after restart', + async (prefix) => { + const fixture = await openAcpFixtureRig() + const frames = await readAcpFixture('s6-background') + await fixture.feed(frames.slice(0, 5)) + fixture.apply(fixture.lane().notification(`${prefix}task_backgrounded`, started(), 1010)) + await fixture.feed(frames.slice(5)) + const [before] = await taskRows(fixture) + expect(await taskRows(fixture)).toHaveLength(1) + expect(before?.block.label).toBe('sleep 15; echo bg-done > bg-marker.txt') + fixture.restart() + fixture.apply( + fixture + .lane() + .notification( + `${prefix}task_completed`, + completed({ exit_code: 0, output: 'Finished' }), + 2000 + ) + ) + const [after] = await taskRows(fixture) + expect(after?.row.itemId).toBe(before?.row.itemId) + expect(after?.row.turnScope).toEqual(before?.row.turnScope) + expect(after?.block).toMatchObject({ + state: 'done', + summary: 'Finished', + label: before?.block.label, + parentToolUseId: 'call-1' + }) + expect(await fixture.rig.turns()).toHaveLength(2) + fixture.apply(fixture.lane().notification(`${prefix}task_backgrounded`, started(), 2001)) + expect((await taskRows(fixture))[0]?.block.state).toBe('done') + } + ) + + it.each([ + [{ exit_code: 2, error: 'Command failed' }, 'blocked'], + [{ exit_code: null, signal: 'SIGTERM' }, 'blocked'], + [{ exit_code: null, explicitly_killed: true }, 'idle'], + [{ exit_code: null }, 'unverifiable'] + ] as const)( + 'uses the provider outcome %j without claiming unseen success', + async (snapshot, state) => { + const fixture = await openAcpFixtureRig() + await fixture.feed(await readAcpFixture('s6-background')) + fixture.apply(fixture.lane().notification('x.ai/task_completed', completed(snapshot), 2000)) + expect((await taskRows(fixture))[0]?.block.state).toBe(state) + } + ) + + it('recovers partial task metadata from the journal after bounded snapshots are evicted', async () => { + const fixture = await openAcpFixtureRig() + await fixture.feed(await readAcpFixture('s6-background')) + for (let index = 0; index < 130; index += 1) { + fixture.apply( + fixture + .lane() + .notification('x.ai/task_backgrounded', started(`task-${index}`), 1500 + index) + ) + } + await fixture.rig.rows() + fixture.apply(fixture.lane().notification('x.ai/task_completed', completed(), 2000)) + const original = (await taskRows(fixture)).find((task) => task.block.taskId === taskId) + expect(original?.block).toMatchObject({ + state: 'done', + parentToolUseId: 'call-1', + outputFile: '/workspace/file-1', + kind: 'command' + }) + }) + + it('ignores malformed, other-session and replay-only task notifications without opening turns', async () => { + const fixture = await openAcpFixtureRig() + for (const params of [ + { sessionId: 'session-1', update: { sessionUpdate: 'task_completed' } }, + { ...started(), sessionId: 'other' }, + { ...started(), _meta: { isReplay: true } } + ]) { + fixture.apply(fixture.lane().notification('_x.ai/task_backgrounded', params, 1000)) + } + expect(await fixture.rig.rows()).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-timeline-recordings.test.ts b/src/main/acp/acp-timeline-recordings.test.ts index 7155e690f69d..e3dfe80ef58a 100644 --- a/src/main/acp/acp-timeline-recordings.test.ts +++ b/src/main/acp/acp-timeline-recordings.test.ts @@ -128,7 +128,9 @@ describe('recorded ACP traffic through the journal', () => { ['completed', 'success'], ['completed', 'success'] ]) - const messages = rows.filter((row) => row.body.kind === 'message') + const messages = rows.filter( + (row) => row.body.kind === 'message' && row.body.role === 'assistant' + ) expect(messages).toHaveLength(2) expect(messageText(messages[1]!.body)).toBe('The background') expect(messages[0]!.turnScope).not.toEqual(messages[1]!.turnScope) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 0867dcf40c12..7fb83a6de94b 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -5,7 +5,8 @@ import type { } from '../../shared/agent-session-journal-types' import { BoundedMap } from '../../shared/bounded-map' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' -import { acpNotificationEnvelopeSchema } from './acp-context-usage' +import { acpNotificationEnvelopeSchema, AcpContextTimeline } from './acp-context-usage' +import { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' import { GENERIC_ACP_DIALECT, type AcpDialect, @@ -42,14 +43,16 @@ export class AcpTimelineTranslator { private readonly dialect: AcpDialect private readonly prompts: AcpPromptTurns private readonly tools = new AcpToolTimeline() + private readonly backgroundTasks: AcpBackgroundTaskTimeline private readonly messages = new AcpTurnMessages() private readonly started = new BoundedMap({ maxEntries: 128 }) private replay?: LoadReplay - private window?: { tokens: number; capturedAt: number } + private readonly context = new AcpContextTimeline() private activeTurn?: string constructor(private readonly options: AcpTimelineTranslatorOptions) { this.dialect = options.dialect ?? GENERIC_ACP_DIALECT + this.backgroundTasks = new AcpBackgroundTaskTimeline(options.journalItems) this.prompts = new AcpPromptTurns( options.sessionId, this.dialect.injectedPromptIdentity === true @@ -96,18 +99,7 @@ export class AcpTimelineTranslator { } contextModels(models: unknown, at: number): ProviderTimelineEvent[] { - const tokens = this.dialect.contextWindow?.(models) - if (tokens === undefined) { - return [] - } - this.window = { tokens, capturedAt: at } - return [ - { - type: 'context.usage', - usage: { window: this.window }, - join: { thread: this.options.sessionId } - } - ] + return this.context.models(models, at, this.dialect, { thread: this.options.sessionId }) } beginLoad(): void { @@ -230,13 +222,11 @@ export class AcpTimelineTranslator { } } const join = { thread: this.options.sessionId, ...(turn === undefined ? {} : { turn }) } + if (extension?.backgroundTasks) { + events.push(...this.backgroundTasks.translate(extension.backgroundTasks, join)) + } if (extension?.usage) { - this.window = extension.usage.window ?? this.window - events.push({ - type: 'context.usage', - usage: { ...extension.usage, ...(this.window ? { window: this.window } : {}) }, - join - }) + events.push(...this.context.update(extension.usage, join)) } if (extension?.end && turn) { if ( @@ -267,6 +257,7 @@ export class AcpTimelineTranslator { isReplay, this.tools, this.dialect, + this.backgroundTasks, replayUser?.body, messageKey ) diff --git a/src/main/claude/claude-background-task-row-journal.ts b/src/main/claude/claude-background-task-row-journal.ts index 45f8dfc2ae17..1419729c5cf1 100644 --- a/src/main/claude/claude-background-task-row-journal.ts +++ b/src/main/claude/claude-background-task-row-journal.ts @@ -1,13 +1,9 @@ import type { - AgentJournalItemBody, AgentJournalItemIdentity, AgentJournalTurnScope } from '../../shared/agent-session-journal-types' -import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row' -import { - isBackgroundTaskBlock, - type NativeChatBackgroundTaskBlock -} from '../../shared/native-chat-types' +import { backgroundTaskJournalBody } from '../../shared/native-chat-background-task-row' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' import type { StructuredAgentSessionEventSink, StructuredAgentSessionLifecycleJournal, @@ -37,15 +33,7 @@ export function claudeBackgroundTaskIdentity( return { provider: 'orca', clientMessageId: key } } -export function claudeBackgroundTaskBody( - block: NativeChatBackgroundTaskBlock -): AgentJournalItemBody { - return { - kind: 'message', - role: 'system', - blocks: [{ type: 'text', text: backgroundTaskFallbackText(block) }, { ...block }] - } -} +export const claudeBackgroundTaskBody = backgroundTaskJournalBody /** Reconcile one queued row against the durable run identity after a rebind. */ export function resolveClaudeBackgroundTaskIdentity( diff --git a/src/shared/native-chat-background-task-row.ts b/src/shared/native-chat-background-task-row.ts index 9be089b74865..717c8691386a 100644 --- a/src/shared/native-chat-background-task-row.ts +++ b/src/shared/native-chat-background-task-row.ts @@ -5,6 +5,7 @@ // the block. The frozen sentence beside it is built here too, so the twin and // the block can never describe the task differently. +import type { AgentJournalMessageItem } from './agent-session-journal-types' import { isBackgroundTaskBlock, type NativeChatBackgroundTaskBlock, @@ -113,6 +114,16 @@ export function backgroundTaskFallbackText(block: NativeChatBackgroundTaskBlock) return `${subject.charAt(0).toUpperCase()}${subject.slice(1)} ${verb}` } +export function backgroundTaskJournalBody( + block: NativeChatBackgroundTaskBlock +): AgentJournalMessageItem { + return { + kind: 'message', + role: 'system', + blocks: [{ type: 'text', text: backgroundTaskFallbackText(block) }, { ...block }] + } +} + /** The background-task rows in `blocks`. */ export function backgroundTaskBlocks( blocks: readonly NativeChatBlock[] From b4e29e06b5194af94e07880324e455823a2dc424 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:21:41 -0700 Subject: [PATCH 056/269] Recover background task origin from its persisted launch tool --- src/main/acp/acp-background-task-timeline.ts | 12 +++++- src/main/acp/acp-journal-turns.ts | 10 +++++ .../acp/acp-timeline-background-tasks.test.ts | 39 +++++++++++++++++++ src/main/acp/acp-timeline-requests.ts | 12 +----- src/main/acp/acp-timeline-translator.ts | 4 +- 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/main/acp/acp-background-task-timeline.ts b/src/main/acp/acp-background-task-timeline.ts index 141e2f480237..46cfd3117639 100644 --- a/src/main/acp/acp-background-task-timeline.ts +++ b/src/main/acp/acp-background-task-timeline.ts @@ -14,6 +14,7 @@ import type { ProviderTimelineJoin } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpBackgroundTaskUpdate } from './acp-dialects/acp-dialect' +import { acpJournalToolTurn } from './acp-journal-turns' /** Partial provider snapshots only; the assembler owns settlement and durable placement. */ export class AcpBackgroundTaskTimeline { @@ -23,7 +24,10 @@ export class AcpBackgroundTaskTimeline { sizeOf: (block, key) => Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(block)) }) - constructor(private readonly journalItems: () => readonly AgentJournalRenderItem[]) {} + constructor( + private readonly journalItems: () => readonly AgentJournalRenderItem[], + private readonly toolTurn: (callId: string) => string | undefined + ) {} translate( updates: AcpBackgroundTaskUpdate[], @@ -45,11 +49,15 @@ export class AcpBackgroundTaskTimeline { } } this.snapshots.set(block.taskId, block) + const turn = block.parentToolUseId + ? (this.toolTurn(block.parentToolUseId) ?? + acpJournalToolTurn(this.journalItems(), block.parentToolUseId)) + : undefined return { type: 'item.update', item: `background-task:${block.taskId}`, body: backgroundTaskJournalBody(block), - join + join: { ...join, ...(turn === undefined ? {} : { turn }) } } }) } diff --git a/src/main/acp/acp-journal-turns.ts b/src/main/acp/acp-journal-turns.ts index 15d48e78e8c4..6d1b95c71274 100644 --- a/src/main/acp/acp-journal-turns.ts +++ b/src/main/acp/acp-journal-turns.ts @@ -19,6 +19,16 @@ export function acpJournalTurnKey(row: AgentJournalRenderItem): string | undefin } } +export function acpJournalToolTurn( + rows: readonly AgentJournalRenderItem[], + callId: string +): string | undefined { + const tool = rows.find((row) => row.body.kind === 'tool-call' && row.body.callId === callId) + const turnItemId = tool?.turnScope?.kind === 'turn' ? tool.turnScope.turnItemId : undefined + const turn = rows.find((row) => row.itemId === turnItemId) + return turn && acpJournalTurnKey(turn) +} + export function acpJournalTurnIsSettled( rows: readonly AgentJournalRenderItem[], key: string diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts index 60c6f9565c8a..9dae05d93853 100644 --- a/src/main/acp/acp-timeline-background-tasks.test.ts +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -156,6 +156,45 @@ describe('Grok background tasks through the shared timeline', () => { }) }) + it('places a first task notice after restart beside its original tool while a different turn is active', async () => { + const fixture = await openAcpFixtureRig() + await fixture.feed((await readAcpFixture('s6-background')).slice(0, 5)) + fixture.apply( + fixture.lane().notification( + 'session/update', + { + sessionId: 'session-1', + update: { sessionUpdate: 'tool_call_update', toolCallId: 'call-1', status: 'completed' } + }, + 1200 + ) + ) + fixture.apply(fixture.lane().promptResult('p1:3', { stopReason: 'end_turn' }, 1300)) + const original = (await fixture.rig.rows()).find((row) => row.body.kind === 'tool-call') + fixture.restart() + fixture.apply(fixture.lane().openPrompt('next', 1400).events) + fixture.apply( + fixture.lane().notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: 'prompt:next' }, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Next reply' } + } + }, + 1500 + ) + ) + fixture.apply(fixture.lane().notification('x.ai/task_backgrounded', started(), 1600)) + expect((await taskRows(fixture))[0]?.row.turnScope).toEqual(original?.turnScope) + expect((await fixture.rig.turns()).map((turn) => turn.state)).toEqual(['completed', 'running']) + fixture.apply(fixture.lane().notification('x.ai/task_completed', completed(), 1700)) + expect((await taskRows(fixture))[0]?.block.state).toBe('done') + expect((await fixture.rig.turns())[1]?.state).toBe('running') + }) + it('ignores malformed, other-session and replay-only task notifications without opening turns', async () => { const fixture = await openAcpFixtureRig() for (const params of [ diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts index 16fe309d7516..23bc215e80b7 100644 --- a/src/main/acp/acp-timeline-requests.ts +++ b/src/main/acp/acp-timeline-requests.ts @@ -2,7 +2,7 @@ import { z } from 'zod' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect, AcpRequestPresentation } from './acp-dialects/acp-dialect' -import { acpJournalTurnKey } from './acp-journal-turns' +import { acpJournalToolTurn } from './acp-journal-turns' import type { AcpToolTimeline } from './acp-tool-timeline' import { AcpRpcError } from './acp-errors' import { RequestPermissionRequestSchema } from './generated/acp-protocol.generated' @@ -70,16 +70,8 @@ export function translateAcpRequest( : options.dialect.request?.(method, params) const tool = requestToolSchema.safeParse(params) const callId = tool.success ? (tool.data.toolCall?.toolCallId ?? tool.data.toolCallId) : undefined - const storedTool = callId - ? options - .journalItems() - .find((row) => row.body.kind === 'tool-call' && row.body.callId === callId) - : undefined - const turnItemId = - storedTool?.turnScope?.kind === 'turn' ? storedTool.turnScope.turnItemId : undefined - const storedTurn = options.journalItems().find((row) => row.itemId === turnItemId) const turn = callId - ? (options.tools.turn(callId) ?? (storedTurn && acpJournalTurnKey(storedTurn))) + ? (options.tools.turn(callId) ?? acpJournalToolTurn(options.journalItems(), callId)) : undefined const join = { thread: options.sessionId, ...(turn === undefined ? {} : { turn }) } if (!presentation) { diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 7fb83a6de94b..a99036e1a8c1 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -52,7 +52,9 @@ export class AcpTimelineTranslator { constructor(private readonly options: AcpTimelineTranslatorOptions) { this.dialect = options.dialect ?? GENERIC_ACP_DIALECT - this.backgroundTasks = new AcpBackgroundTaskTimeline(options.journalItems) + this.backgroundTasks = new AcpBackgroundTaskTimeline(options.journalItems, (callId) => + this.tools.turn(callId) + ) this.prompts = new AcpPromptTurns( options.sessionId, this.dialect.injectedPromptIdentity === true From efe2df4e9a0fa07b825093d2523a7ff97db81635 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:24:03 -0700 Subject: [PATCH 057/269] Restate background task fallback from its terminal evidence --- .../acp/acp-dialects/grok-background-tasks.ts | 15 +++++++++++---- .../acp/acp-timeline-background-tasks.test.ts | 1 + 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/acp/acp-dialects/grok-background-tasks.ts b/src/main/acp/acp-dialects/grok-background-tasks.ts index 6813f195ec57..d2c221e79227 100644 --- a/src/main/acp/acp-dialects/grok-background-tasks.ts +++ b/src/main/acp/acp-dialects/grok-background-tasks.ts @@ -1,5 +1,8 @@ import { z } from 'zod' -import { normalizeBackgroundTaskKind } from '../../../shared/native-chat-background-task-row' +import { + isSettledBackgroundTaskState, + normalizeBackgroundTaskKind +} from '../../../shared/native-chat-background-task-row' import type { NativeChatBackgroundTaskBlock } from '../../../shared/native-chat-types' import type { ToolCallUpdate } from '../generated/acp-protocol.generated' import type { AcpBackgroundTaskUpdate, AcpDialectNotification } from './acp-dialect' @@ -53,10 +56,14 @@ function snapshot( : {}), ...(label === undefined ? {} : { label }), ...(task.output_file === undefined ? {} : { outputFile: task.output_file }), - ...(task.summary === undefined && task.output === undefined + ...(task.summary === undefined && + task.output === undefined && + !isSettledBackgroundTaskState(state) ? {} - : { summary: task.summary ?? task.output }), - ...(task.error === undefined ? {} : { error: task.error }) + : { summary: task.summary ?? task.output ?? '' }), + ...(task.error === undefined && !isSettledBackgroundTaskState(state) + ? {} + : { error: task.error ?? '' }) } } diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts index 9dae05d93853..d62973d7da3d 100644 --- a/src/main/acp/acp-timeline-background-tasks.test.ts +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -154,6 +154,7 @@ describe('Grok background tasks through the shared timeline', () => { outputFile: '/workspace/file-1', kind: 'command' }) + expect(original && backgroundTaskFallbackText(original.block)).toContain('finished') }) it('places a first task notice after restart beside its original tool while a different turn is active', async () => { From d400e03da0edbc9d3998147ae5d6eac8563be51f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:33:17 -0700 Subject: [PATCH 058/269] Require session identity on Grok task notifications --- src/main/acp/acp-dialects/grok-background-tasks.ts | 2 +- src/main/acp/acp-timeline-background-tasks.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/acp/acp-dialects/grok-background-tasks.ts b/src/main/acp/acp-dialects/grok-background-tasks.ts index d2c221e79227..1f354b26f98a 100644 --- a/src/main/acp/acp-dialects/grok-background-tasks.ts +++ b/src/main/acp/acp-dialects/grok-background-tasks.ts @@ -82,7 +82,7 @@ export function grokBackgroundTaskNotification( return undefined } const parsed = z - .object({ update: z.union([backgroundedSchema, completedSchema]) }) + .object({ sessionId: z.string(), update: z.union([backgroundedSchema, completedSchema]) }) .safeParse(params) if (!parsed.success) { return { disposition: 'ignore' } diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts index d62973d7da3d..5b0f5cd18531 100644 --- a/src/main/acp/acp-timeline-background-tasks.test.ts +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -200,6 +200,7 @@ describe('Grok background tasks through the shared timeline', () => { const fixture = await openAcpFixtureRig() for (const params of [ { sessionId: 'session-1', update: { sessionUpdate: 'task_completed' } }, + { update: started().update }, { ...started(), sessionId: 'other' }, { ...started(), _meta: { isReplay: true } } ]) { From da17b3ef0fb6d1f9721758bc72f3e3ea885f5a18 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:39:46 -0700 Subject: [PATCH 059/269] Read a tool call a stop or the session's end cut short as interrupted, not failed A call still running when its turn was interrupted, when the provider child was seen to exit, or that the provider cancelled now carries `endedAs: 'interrupted'` beside `state: 'failed'`. Every build that predates the field keeps reading the call as the failure it always showed; this build reads both through one shared reader and counts the call apart from failures in the run header, without the error tint on its partial output. The shared assembler, the restart settlement and the Codex lane settle a running call through one rule: only a proven interruption cuts it short, so a turn the provider completed around an unclosed call, or work the host lost track of, still reads failed. --- ...codex-persistent-command-retention.test.ts | 2 + .../codex-structured-journal-settlement.ts | 18 ++- ...red-journal-translation-settlement.test.ts | 36 ++++- ...red-journal-translation-turn-boundaries.ts | 1 + .../provider-timeline-assembler-items.test.ts | 5 +- ...rovider-timeline-assembler-session.test.ts | 3 +- ...vider-timeline-assembler-tool-ends.test.ts | 119 ++++++++++++++++ .../provider-timeline-settlement.ts | 8 +- ...session-dead-generation-settlement.test.ts | 16 +++ ...gent-session-dead-generation-settlement.ts | 16 ++- .../native-chat/NativeChatToolRun.tsx | 34 ++--- .../NativeChatToolRunCallCounts.test.tsx | 85 +++++++++++ .../NativeChatToolRunCallCounts.tsx | 58 ++++++++ .../src/i18n/en-runtime-required.json | 2 + src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/i18n/locales/fr.json | 2 + src/renderer/src/i18n/locales/ja.json | 2 + src/renderer/src/i18n/locales/ko.json | 2 + src/renderer/src/i18n/locales/zh.json | 2 + .../agent-journal-tool-call-lifecycle.ts | 43 ++++++ src/shared/agent-session-journal-schemas.ts | 42 +++--- src/shared/agent-session-journal-types.ts | 8 ++ src/shared/native-chat-tool-activity.ts | 5 +- .../native-chat-tool-run-outcome.test.ts | 38 +++++ src/shared/native-chat-tool-run-outcome.ts | 22 ++- src/shared/native-chat-types.ts | 8 +- ...tructured-agent-session-projection.test.ts | 19 +++ .../structured-agent-session-projection.ts | 4 +- ...tructured-agent-session-tool-call-block.ts | 1 + ...on-interrupted-tool-downgrade.unit.test.ts | 132 ++++++++++++++++++ 30 files changed, 660 insertions(+), 75 deletions(-) create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-tool-ends.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatToolRunCallCounts.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatToolRunCallCounts.tsx create mode 100644 src/shared/agent-journal-tool-call-lifecycle.ts create mode 100644 tests/e2e/cross-version-wire/agent-session-interrupted-tool-downgrade.unit.test.ts diff --git a/src/main/codex/codex-persistent-command-retention.test.ts b/src/main/codex/codex-persistent-command-retention.test.ts index fa6aef48ae9f..0f410c6b362d 100644 --- a/src/main/codex/codex-persistent-command-retention.test.ts +++ b/src/main/codex/codex-persistent-command-retention.test.ts @@ -117,6 +117,7 @@ describe('persistent command retention', () => { threadId: `thread-${thread}`, turnId: 'turn', turnLifecycle: null, + turnEnd: 'completed', sink, streams: items.streams, activeItems: items.activeItems, @@ -213,6 +214,7 @@ describe('persistent command retention', () => { threadId: 'root', turnId: 'turn', turnLifecycle: null, + turnEnd: 'completed', sink, streams: items.streams, activeItems: items.activeItems, diff --git a/src/main/codex/codex-structured-journal-settlement.ts b/src/main/codex/codex-structured-journal-settlement.ts index e7d0a376eca8..3cb6c8182fd6 100644 --- a/src/main/codex/codex-structured-journal-settlement.ts +++ b/src/main/codex/codex-structured-journal-settlement.ts @@ -1,8 +1,10 @@ +import { endedRunningAgentJournalToolCall } from '../../shared/agent-journal-tool-call-lifecycle' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemBody, type AgentJournalItemIdentity, - type AgentJournalTurnLifecycle + type AgentJournalTurnLifecycle, + type AgentJournalTurnLifecycleState } from '../../shared/agent-session-journal-types' import { journalLifecycleItemMutation, @@ -70,7 +72,8 @@ export function settleCodexJournalSession(input: { const translated = streamed ? codexStreamingJournalItem(active.item, streamed.text) : codexJournalItem(active.item, active.helperName) - const body = interruptedBody(translated.body) + // The host saw the child go, so its work was cut short. + const body = settledActiveBody(translated.body, 'interrupted') if (body) { mutations.push(settledRow(input.attributionFor, active, body)) } @@ -118,6 +121,8 @@ export function settleCodexJournalTurn(input: { turnId: string /** Null off the primary thread: only the primary turn owns a lifecycle row. */ turnLifecycle: AgentJournalTurnLifecycle | null + /** How Codex ended the turn, on every thread: what a call it left running became. */ + turnEnd: Extract sink: StructuredAgentSessionEventSink streams: CodexStructuredItemStreams activeItems: Map @@ -142,7 +147,7 @@ export function settleCodexJournalTurn(input: { const translated = streamed ? codexStreamingJournalItem(active.item, streamed.text) : codexJournalItem(active.item, active.helperName) - const body = interruptedBody(translated.body) + const body = settledActiveBody(translated.body, input.turnEnd) if (body) { mutations.push(settledRow(input.attributionFor, active, body)) } @@ -196,12 +201,15 @@ function settledRow( return journalLifecycleItemMutation(attributionFor(row.threadId, row.turnId), row.identity, body) } -function interruptedBody(body: AgentJournalItemBody | null): AgentJournalItemBody | null { +function settledActiveBody( + body: AgentJournalItemBody | null, + turnEnd: 'completed' | 'interrupted' +): AgentJournalItemBody | null { if (!body) { return null } if (body.kind === 'tool-call') { - return { ...body, state: 'failed' } + return endedRunningAgentJournalToolCall(body, turnEnd) } if (body.kind === 'message') { return body diff --git a/src/main/codex/codex-structured-journal-translation-settlement.test.ts b/src/main/codex/codex-structured-journal-translation-settlement.test.ts index ea25ad2dcfda..400a4dd6bfd0 100644 --- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts +++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts @@ -336,8 +336,13 @@ describe('codex journal translation', () => { const mutations = batches.at(-1)?.mutations ?? [] expect(mutations).toEqual( expect.arrayContaining([ + // The host saw the child go, so the call it was running was cut short. expect.objectContaining({ - body: expect.objectContaining({ kind: 'tool-call', state: 'failed' }) + body: expect.objectContaining({ + kind: 'tool-call', + state: 'failed', + endedAs: 'interrupted' + }) }), expect.objectContaining({ body: expect.objectContaining({ @@ -563,6 +568,35 @@ describe('codex journal translation', () => { ]) }) + it('cuts short an active tool when Codex reports its turn interrupted', () => { + const tap = recorder() + const bodies: unknown[] = [] + tap.sink.appendLifecycleBatch = (_settlementId, mutations) => { + bodies.push( + ...mutations.flatMap((mutation) => (mutation.kind === 'item' ? [mutation.body] : [])) + ) + } + const translator = createCodexJournalTranslator({ + sink: tap.sink, + primaryThreadId: () => THREAD_ID + }) + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { + item: { type: 'commandExecution', id: 'exec-active', command: 'run', status: 'inProgress' } + }) + ) + translator.handle( + notification('turn/completed', { turn: { id: TURN_ID, status: 'interrupted' } }) + ) + + expect(bodies).toEqual([ + expect.objectContaining({ kind: 'tool-call', state: 'failed', endedAs: 'interrupted' }), + expect.objectContaining({ kind: 'turn', turnId: TURN_ID, state: 'interrupted' }) + ]) + }) + it('journals an approval naming the command the item already announced, and binds it', () => { const { translator, tap } = translatorWith() diff --git a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts index a853ca2bfd8f..5c930eb9f927 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts @@ -193,6 +193,7 @@ export class CodexJournalTurnBoundaries { threadId: event.threadId, turnId, turnLifecycle, + turnEnd: codexTurnLifecycleState(status), streams: this.deps.items.streams, activeItems: this.deps.items.activeItems, pendingPrompts: this.deps.pendingPrompts, diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts index 7d6a12178a11..3d204e0bf0f3 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts @@ -135,7 +135,7 @@ describe('provider timeline items', () => { expect(rows[0]?.turnScope).toEqual({ kind: 'thread' }) }) - it('fails a tool call its turn left running, and leaves a background task it started running', async () => { + it('cuts short a tool call its interrupted turn left running, and leaves a background task it started running', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) @@ -151,7 +151,8 @@ describe('provider timeline items', () => { outcome: 'cancellation' }) expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ - state: 'failed' + state: 'failed', + endedAs: 'interrupted' }) expect(await backgroundTaskState(rig, 'bg-1')).toBe('working') diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts index 3bc3aeb3caa5..c1c72b2f0119 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts @@ -43,7 +43,8 @@ describe('provider timeline session end', () => { expect(turn).toMatchObject({ state: 'interrupted', completedAt: 4_000, startedAt: 1_000 }) expect(turn).not.toHaveProperty('outcome') expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ - state: 'failed' + state: 'failed', + endedAs: 'interrupted' }) expect((await rig.row(providerItemId('request', 'perm-1')))?.body).toMatchObject({ resolution: { state: 'cancelled', selectedOptionId: null } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-tool-ends.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-tool-ends.test.ts new file mode 100644 index 000000000000..eb9a4645cae4 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-tool-ends.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + agentJournalToolCallLifecycle, + interruptedAgentJournalToolCall +} from '../../../shared/agent-journal-tool-call-lifecycle' +import type { AgentJournalToolCallItem } from '../../../shared/agent-session-journal-types' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + providerItemId, + runningTool, + type ProviderTimelineRig +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +async function toolBody( + rig: ProviderTimelineRig, + item = 'call-a' +): Promise { + const body = (await rig.row(providerItemId('item', item)))?.body + if (body?.kind !== 'tool-call') { + throw new Error(`no tool row for ${item}`) + } + return body +} + +async function lifecycle(rig: ProviderTimelineRig, item = 'call-a'): Promise { + return agentJournalToolCallLifecycle(await toolBody(rig, item)) +} + +async function openTool(): Promise { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('shell') }) + return rig +} + +describe('provider timeline: how a call its turn or session ended reads', () => { + it('reads interrupted when a stop interrupts its turn, keeping failed for older builds', async () => { + const rig = await openTool() + rig.assembler.apply({ + type: 'turn.end', + at: 2_000, + state: 'interrupted', + outcome: 'cancellation' + }) + expect(await toolBody(rig)).toMatchObject({ state: 'failed', endedAs: 'interrupted' }) + expect(await lifecycle(rig)).toBe('interrupted') + }) + + it('reads interrupted when a newer turn supersedes its turn', async () => { + const rig = await openTool() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-2', at: 2_000 }) + expect(await lifecycle(rig)).toBe('interrupted') + }) + + it('reads interrupted when the session ends on an observed child exit', async () => { + const rig = await openTool() + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 3_000 } + }) + expect(await lifecycle(rig)).toBe('interrupted') + }) + + it('reads interrupted when a restarted host ends the session a previous one opened it in', async () => { + const rig = await openTool() + const restarted = rig.restart() + restarted.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 3_000 } + }) + expect(await lifecycle(rig)).toBe('interrupted') + }) + + it('keeps the provider cancelling a call, and a later turn end does not restate it', async () => { + const rig = await openTool() + rig.assembler.apply({ + type: 'item.close', + item: 'call-a', + body: interruptedAgentJournalToolCall(runningTool('shell')) + }) + expect(await lifecycle(rig)).toBe('interrupted') + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + expect(await lifecycle(rig)).toBe('interrupted') + }) + + it('still reads failed when the provider completed the turn around a call it never closed', async () => { + const rig = await openTool() + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + const body = await toolBody(rig) + expect(body).toMatchObject({ state: 'failed' }) + expect(body).not.toHaveProperty('endedAs') + }) + + it('still reads failed when the host lost the child, which proves no interruption', async () => { + const rig = await openTool() + rig.assembler.apply({ type: 'session.ended', verdict: { state: 'unverifiable' } }) + expect(await lifecycle(rig)).toBe('failed') + }) + + it('still reads failed when a new provider session replaces the one it ran in', async () => { + const rig = await openTool() + rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) + expect(await lifecycle(rig)).toBe('failed') + }) + + it('leaves a call that finished before the stop as it finished', async () => { + const rig = await openTool() + rig.assembler.apply({ + type: 'item.close', + item: 'call-a', + body: { ...runningTool('shell'), state: 'completed' } + }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'interrupted' }) + expect(await lifecycle(rig)).toBe('completed') + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts index dd4e92166551..5ed5911ff522 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -6,6 +6,7 @@ // answered a moment earlier stays answered, a row a previous assembler opened is settled too, and // a second settlement finds nothing left to do. +import { endedRunningAgentJournalToolCall } from '../../../shared/agent-journal-tool-call-lifecycle' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -38,10 +39,11 @@ export type ProviderTimelineTurnEnd = /** What a row still open when its turn or the session ends becomes; null when it already stands. */ export function settledProviderTimelineBody( - body: AgentJournalItemBody + body: AgentJournalItemBody, + end: ProviderTimelineTurnEnd ): AgentJournalItemBody | null { if (body.kind === 'tool-call') { - return body.state === 'running' ? { ...body, state: 'failed' } : null + return body.state === 'running' ? endedRunningAgentJournalToolCall(body, end.state) : null } if (body.kind === 'approval' || body.kind === 'question') { return body.resolution.state === 'pending' ? cancelledJournalPromptBody(body) : null @@ -93,7 +95,7 @@ export function providerTimelineSettlement( // Background tasks outlive turns; only the session's end leaves them past seeing. const settled = !covered ? null - : (settledProviderTimelineBody(body) ?? + : (settledProviderTimelineBody(body, end) ?? (scope === 'session' ? lostProviderTimelineBackgroundTasks(body) : null)) const identity = settled ? parseAgentJournalItemKey(itemId) : null if (settled && identity) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index b39790e6b2af..09afb2ee5f21 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -108,6 +108,7 @@ describe('dead structured-session generation settlement', () => { ]) expect(snapshot.items.map((item) => item.body)).toEqual( expect.arrayContaining([ + // An unverifiable end proves no interruption. expect.objectContaining({ kind: 'tool-call', state: 'failed' }), expect.objectContaining({ kind: 'approval', @@ -150,6 +151,21 @@ describe('dead structured-session generation settlement', () => { ).toHaveLength(1) }) + it('cuts short a call the proven death interrupted', async () => { + await seedUnfinishedWork() + await settleStructuredAgentSessionDeadGeneration({ + journal, + sessionId: SESSION, + fence: 7, + settlementId: `provider-exit:${SESSION}:7:generation-1`, + pendingSubmissionReason: 'provider_exited_before_acknowledgement', + verdict: { state: 'interrupted', completedAt: 1_000 }, + showUnexpectedExitOutcome: true + }) + const tool = journal.snapshot().items.find((item) => item.body.kind === 'tool-call') + expect(tool?.body).toMatchObject({ state: 'failed', endedAs: 'interrupted' }) + }) + it('keeps a stderr wall out of the sentence, as a bounded detail for a log', async () => { await seedUnfinishedWork() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 39ff1c7ba514..dd69ea9e23b4 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 @@ -3,6 +3,7 @@ import { MAX_PROVIDER_DIAGNOSTIC_CHARS, type SubmissionRejectionFact } from '../../../shared/agent-session-failure' +import { endedRunningAgentJournalToolCall } from '../../../shared/agent-journal-tool-call-lifecycle' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { @@ -31,6 +32,7 @@ import { runningTurnLifecycleRevisions, stopFoundTurnLiveAt, turnVerdictFromDeathEvidence, + UNVERIFIABLE_TURN_VERDICT, type StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' import { @@ -175,7 +177,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { } for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) - const body = terminalDeadGenerationBody(item) + const body = terminalDeadGenerationBody(item, input.verdict) if (identity && body) { mutations.push({ kind: 'item', @@ -233,7 +235,7 @@ export async function settleStaleStructuredAgentSessionState(input: { const mutations: JournalLifecycleMutationInput[] = [] for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) - const body = terminalDeadGenerationBody(item) + const body = terminalDeadGenerationBody(item, verdictFor(item)) if (identity && body) { mutations.push({ kind: 'item', @@ -287,9 +289,13 @@ export async function settleStaleStructuredAgentSessionState(input: { return mutations.length } -function terminalDeadGenerationBody(item: AgentJournalRenderItem): AgentJournalItemBody | null { +function terminalDeadGenerationBody( + item: AgentJournalRenderItem, + verdict: StructuredAgentSessionTurnVerdict +): AgentJournalItemBody | null { if (item.body.kind === 'tool-call' && item.body.state === 'running') { - return { ...item.body, state: 'failed' } + // Ended as its turn is: a proven death cuts it short. + return endedRunningAgentJournalToolCall(item.body, verdict.state) } if (item.body.kind === 'approval' || item.body.kind === 'question') { return item.body.resolution.state === 'pending' ? cancelledJournalPromptBody(item.body) : null @@ -300,7 +306,7 @@ function terminalDeadGenerationBody(item: AgentJournalRenderItem): AgentJournalI function isUnfinishedItem(item: AgentJournalRenderItem): boolean { return ( readAgentJournalTurn(item.body)?.state === 'running' || - terminalDeadGenerationBody(item) !== null + terminalDeadGenerationBody(item, UNVERIFIABLE_TURN_VERDICT) !== null ) } diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index 1b604822e8f6..d0210aa3da27 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -43,6 +43,7 @@ import type { NativeChatSubagentRosterState } from './native-chat-subagent-sections' import { NativeChatToolRunIcon } from './NativeChatToolIcon' +import { NativeChatToolRunCallCounts } from './NativeChatToolRunCallCounts' /** Stable empty default: a fresh array literal per render breaks memoization. */ const NO_SUBAGENT_GROUPS: NativeChatSubagentGroupBlock[] = [] @@ -160,9 +161,11 @@ export function NativeChatToolRun({ // not, so a call that finished in a frame still leaves its name until the next. const latestCall = live ? headerBlocks.findLast(isToolCallBlock) : undefined const latestCallLabel = latestCall ? describeLatestToolCall(latestCall) : null - const { succeeded: runSucceeded, failedCallCount } = nativeChatToolRunOutcome(headerBlocks, { - activeTurnIsWorking - }) + const { + succeeded: runSucceeded, + failedCallCount, + interruptedCallCount + } = nativeChatToolRunOutcome(headerBlocks, { activeTurnIsWorking }) // An externally opened run keeps child tools collapsed; normal callers leave // the run's own disclosure independent from the turn status bar. const expandToolLines = expandOverride === undefined ? open : false @@ -272,27 +275,10 @@ export function NativeChatToolRun({ > {runSentence ?? fallbackLabel} - {failedCallCount > 0 ? ( - /* Outside the truncating member list, so the one thing the reader - cannot afford to miss survives a pane too narrow to print it. - Quiet text in the header's own type, not a destructive tint or a - swapped glyph: a tool error is routine work, and the failing - line's own detail is one click away. */ - - {translate( - 'components.native-chat.tool.failedCount', - NATIVE_CHAT_TOOL_ACTIVITY_COPY.failedCount, - { value0: failedCallCount } - )} - - ) : null} + {/* Only a stated success is marked done — see nativeChatToolRunOutcome — and never while live: between two calls nothing is running, and a mark that appeared then would flash on every call. */} diff --git a/src/renderer/src/components/native-chat/NativeChatToolRunCallCounts.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRunCallCounts.test.tsx new file mode 100644 index 000000000000..db84b566d519 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatToolRunCallCounts.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentJournalToolCallItem } from '../../../../shared/agent-session-journal-types' +import { interruptedAgentJournalToolCall } from '../../../../shared/agent-journal-tool-call-lifecycle' +import type { NativeChatBlock } from '../../../../shared/native-chat-types' +import { projectStructuredItemToNativeChat } from '../../../../shared/structured-agent-session-projection' +import { NativeChatToolRun } from './NativeChatToolRun' + +afterEach(cleanup) + +function runHeader(container: HTMLElement): HTMLElement { + const header = container.querySelector('button') + if (!header) { + throw new Error('run header did not render') + } + return header +} + +/** The blocks a structured chat draws for one journal tool row. */ +function projected(itemId: string, body: AgentJournalToolCallItem): NativeChatBlock[] { + return ( + projectStructuredItemToNativeChat({ itemId, sequence: 1, revision: 1, observedAt: 1, body }) + ?.blocks ?? [] + ) +} + +const output = (head: string) => ({ head, digest: 'd', byteLength: head.length, truncated: false }) + +function shell( + command: string, + state: AgentJournalToolCallItem['state'] +): AgentJournalToolCallItem { + return { kind: 'tool-call', name: 'shell', input: { command }, state } +} + +describe('NativeChatToolRun call counts', () => { + it('says a call a stop cut short was interrupted, not failed, and marks no success', () => { + const blocks = projected( + 'sleep', + interruptedAgentJournalToolCall({ + ...shell('sleep 20', 'running'), + output: output('partial') + }) + ) + const { container } = render( + + ) + const header = runHeader(container) + expect(header).toHaveTextContent('1 interrupted') + expect(header).not.toHaveTextContent('failed') + expect(header).toHaveAccessibleName(/Interrupted tool calls: 1/) + expect(header.querySelector('.lucide-check')).toBeNull() + }) + + it('names a failure and an interruption in one quiet mark', () => { + const blocks = [ + ...projected('false', { ...shell('false', 'failed'), output: output('exit 1') }), + ...projected('sleep', interruptedAgentJournalToolCall(shell('sleep 20', 'running'))) + ] + const { container } = render( + + ) + const header = runHeader(container) + expect(header).toHaveTextContent('1 failed, 1 interrupted') + expect(header).toHaveAccessibleName(/Failed tool calls: 1, Interrupted tool calls: 1/) + }) + + it('draws the partial output of a cut-short call without the error tint', () => { + const blocks = projected( + 'sleep', + interruptedAgentJournalToolCall({ + ...shell('sleep 20', 'running'), + output: output('partial') + }) + ) + const { container } = render() + const body = [...container.querySelectorAll('pre')].find((pre) => pre.textContent === 'partial') + expect(body).toBeDefined() + expect(body).not.toHaveClass('text-destructive') + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRunCallCounts.tsx b/src/renderer/src/components/native-chat/NativeChatToolRunCallCounts.tsx new file mode 100644 index 000000000000..495edb83e799 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatToolRunCallCounts.tsx @@ -0,0 +1,58 @@ +import { translate } from '@/i18n/i18n' +import { NATIVE_CHAT_TOOL_ACTIVITY_COPY } from '../../../../shared/native-chat-tool-activity' + +/** The settled header's quiet count of calls that did not succeed. A call a stop cut short is + * counted apart from a failure: it did not fail, it was not let finish. */ +export function NativeChatToolRunCallCounts({ + failed, + interrupted +}: { + failed: number + interrupted: number +}): React.JSX.Element | null { + const marks = [ + failed > 0 + ? { + text: translate( + 'components.native-chat.tool.failedCount', + NATIVE_CHAT_TOOL_ACTIVITY_COPY.failedCount, + { value0: failed } + ), + label: translate( + 'components.native-chat.tool.failedCallsLabel', + NATIVE_CHAT_TOOL_ACTIVITY_COPY.failedCallsLabel, + { value0: failed } + ) + } + : null, + interrupted > 0 + ? { + text: translate( + 'components.native-chat.tool.interruptedCount', + NATIVE_CHAT_TOOL_ACTIVITY_COPY.interruptedCount, + { value0: interrupted } + ), + label: translate( + 'components.native-chat.tool.interruptedCallsLabel', + NATIVE_CHAT_TOOL_ACTIVITY_COPY.interruptedCallsLabel, + { value0: interrupted } + ) + } + : null + ].filter((mark) => mark !== null) + if (marks.length === 0) { + return null + } + return ( + /* Outside the truncating member list, so the one thing the reader cannot + afford to miss survives a pane too narrow to print it. Quiet text in the + header's own type, not a destructive tint or a swapped glyph: a tool error + is routine work, and the line's own detail is one click away. */ + mark.label).join(', ')} + className="shrink-0 font-mono text-[11px] text-muted-foreground transition-colors group-hover/tool-run:text-foreground/80" + > + {marks.map((mark) => mark.text).join(', ')} + + ) +} diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index b20c7037d554..b9e8b12ce6a7 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2839,6 +2839,8 @@ "countOne": "1 tool call", "failedCallsLabel": "Failed tool calls: {{value0}}", "failedCount": "{{value0}} failed", + "interruptedCallsLabel": "Interrupted tool calls: {{value0}}", + "interruptedCount": "{{value0}} interrupted", "moreCalls": "+{{value0}} more", "ranCommandManyToolsSummary": "Ran {{commandCount}} command and used {{toolCount}} tools", "ranCommandOneToolSummary": "Ran {{commandCount}} command and used {{toolCount}} tool", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6c160e408dc3..4e5b9c69cd2e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17811,6 +17811,8 @@ "moreCalls": "+{{value0}} more", "failedCount": "{{value0}} failed", "failedCallsLabel": "Failed tool calls: {{value0}}", + "interruptedCount": "{{value0}} interrupted", + "interruptedCallsLabel": "Interrupted tool calls: {{value0}}", "runningPreview": "Running {{preview}}", "runningCommand": "Running command", "runningNamedPreview": "Running {{toolName}} {{preview}}", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 40f29aafcd75..3ef525791a95 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17614,6 +17614,8 @@ "moreCalls": "+{{value0}} plus", "failedCount": "{{value0}} a échoué", "failedCallsLabel": "Appels d'outil ayant échoué : {{value0}}", + "interruptedCount": "{{value0}} interrompu(s)", + "interruptedCallsLabel": "Appels d'outil interrompus : {{value0}}", "runningPreview": "Exécution de {{preview}}", "runningCommand": "Commande en cours d'exécution", "runningNamedPreview": "En cours d'exécution {{toolName}} {{preview}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4870f2cbb29c..3ab139c66242 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17551,6 +17551,8 @@ "moreCalls": "+{{value0}} 詳細", "failedCount": "{{value0}} 件失敗", "failedCallsLabel": "失敗したツール呼び出し: {{value0}}", + "interruptedCount": "{{value0}} 件中断", + "interruptedCallsLabel": "中断されたツール呼び出し: {{value0}}", "runningPreview": "{{preview}} を実行中", "runningCommand": "コマンドの実行", "runningNamedPreview": "{{toolName}} {{preview}} を実行中", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e04189ae70f1..3279933b7b80 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17551,6 +17551,8 @@ "moreCalls": "+{{value0}} 이상", "failedCount": "{{value0}} 실패", "failedCallsLabel": "도구 호출 실패: {{value0}}", + "interruptedCount": "{{value0}} 중단됨", + "interruptedCallsLabel": "중단된 도구 호출: {{value0}}", "runningPreview": "{{preview}} 실행 중", "runningCommand": "실행 명령", "runningNamedPreview": "{{toolName}} {{preview}} 실행 중", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2a9a3ea73b66..1ba6f7fd09a9 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17568,6 +17568,8 @@ "moreCalls": "+{{value0}} 更多", "failedCount": "{{value0}} 失败", "failedCallsLabel": "失败的工具调用:{{value0}}", + "interruptedCount": "{{value0}} 已中断", + "interruptedCallsLabel": "已中断的工具调用:{{value0}}", "runningPreview": "正在运行 {{preview}}", "runningCommand": "运行命令", "runningNamedPreview": "运行 {{toolName}} {{preview}}", diff --git a/src/shared/agent-journal-tool-call-lifecycle.ts b/src/shared/agent-journal-tool-call-lifecycle.ts new file mode 100644 index 000000000000..815f8acc0654 --- /dev/null +++ b/src/shared/agent-journal-tool-call-lifecycle.ts @@ -0,0 +1,43 @@ +// A tool call's lifecycle as readers show it. A call cut short keeps `state: 'failed'`, which every +// build reads, and carries the finer fact beside it: persisted rows outlive the build that wrote +// them, and a new state value would read as a success to a build that only knows these three. + +import type { + AgentJournalToolCallEnding, + AgentJournalToolCallItem, + AgentJournalToolCallState +} from './agent-session-journal-types' + +export type AgentJournalToolCallLifecycle = AgentJournalToolCallState | 'interrupted' + +type ToolCallLifecycleFields = { + state?: AgentJournalToolCallState + endedAs?: AgentJournalToolCallEnding +} + +/** Undefined when the lane reports no lifecycle, as legacy transcripts do. */ +export function agentJournalToolCallLifecycle( + call: ToolCallLifecycleFields +): AgentJournalToolCallLifecycle | undefined { + return call.state === 'failed' && call.endedAs === 'interrupted' ? 'interrupted' : call.state +} + +/** A call that stopped because its turn or session was proven to end, or the provider cancelled + * it: it neither finished nor failed on its own. */ +export function interruptedAgentJournalToolCall( + call: AgentJournalToolCallItem +): AgentJournalToolCallItem { + return { ...call, state: 'failed', endedAs: 'interrupted' } +} + +/** What a call still running when its turn or session ended becomes. Only a proven interruption + * cuts it short: a turn the provider completed around it, or an end the host could not verify, + * proves none, so it reads failed as it always has. */ +export function endedRunningAgentJournalToolCall( + call: AgentJournalToolCallItem, + end: 'interrupted' | 'completed' | 'unverifiable' +): AgentJournalToolCallItem { + return end === 'interrupted' + ? interruptedAgentJournalToolCall(call) + : { ...call, state: 'failed' } +} diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 035bb7d1d01c..90d1f89167cc 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -209,6 +209,21 @@ const FailureFact = z.object({ refusal: z.object({ code: z.string().min(1), details: z.looseObject({}).optional() }).optional() }) +/** A turn's lifecycle, as the turn item and the legacy status row both carry it. */ +const TurnLifecycleFields = { + turnId: z.string(), + state: z.string().min(1), + // Open like `state`: a verdict a newer build writes must not turn the row + // malformed. `readAgentJournalTurnOutcome` is where an unplaceable one + // becomes unknown rather than an arm a caller would act on. + outcome: z.string().min(1).optional(), + userItemId: z.string().min(1).optional(), + startedAt: z.number().finite().positive().optional(), + requestedAt: z.number().finite().positive().optional(), + completedAt: z.number().finite().positive().optional(), + durationMs: z.number().finite().nonnegative().optional() +} + export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ MessageBody, z.object({ @@ -219,6 +234,8 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ input: z.unknown().optional(), callId: ProviderCallId.optional(), state: z.string().min(1), + // Open like `state`: an ending a newer build writes reads as the `state` beside it. + endedAs: z.string().min(1).optional(), output: BoundedPayload.optional() }), z.object({ kind: z.literal('diff'), path: z.string(), patch: BoundedPayload }), @@ -248,35 +265,14 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ text: z.string(), presentation: z.string().optional(), tone: z.string().optional(), - turnLifecycle: z - .object({ - turnId: z.string(), - state: z.string().min(1), - outcome: z.string().min(1).optional(), - userItemId: z.string().min(1).optional(), - startedAt: z.number().finite().positive().optional(), - requestedAt: z.number().finite().positive().optional(), - completedAt: z.number().finite().positive().optional(), - durationMs: z.number().finite().nonnegative().optional() - }) - .optional(), + turnLifecycle: z.object(TurnLifecycleFields).optional(), providerFrame: ProviderFrame.optional(), threadGoal: ThreadGoalState.optional(), failure: FailureFact.optional() }), z.object({ kind: z.literal('turn'), - turnId: z.string(), - state: z.string().min(1), - // Open like `state`: a verdict a newer build writes must not turn the row - // malformed. `readAgentJournalTurnOutcome` is where an unplaceable one - // becomes unknown rather than an arm a caller would act on. - outcome: z.string().min(1).optional(), - userItemId: z.string().min(1).optional(), - startedAt: z.number().finite().positive().optional(), - requestedAt: z.number().finite().positive().optional(), - completedAt: z.number().finite().positive().optional(), - durationMs: z.number().finite().nonnegative().optional(), + ...TurnLifecycleFields, contextUsage: AgentSessionContextUsageSchema.optional(), providerTurnId: z.string().min(1).optional() }) diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 271053b1b214..d0d72f77b342 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -111,6 +111,11 @@ export type AgentJournalMessageItem = { export type AgentJournalToolCallState = 'running' | 'completed' | 'failed' +/** How a call that did not finish on its own ended, finer than its `failed` state. Persisted and + * open for growth: a reader that cannot place a value reads `state`. */ +export const AGENT_JOURNAL_TOOL_CALL_ENDINGS = ['interrupted'] as const +export type AgentJournalToolCallEnding = (typeof AGENT_JOURNAL_TOOL_CALL_ENDINGS)[number] + export type AgentJournalToolCallItem = NativeChatToolMetadata & { kind: 'tool-call' name: string @@ -118,6 +123,9 @@ export type AgentJournalToolCallItem = NativeChatToolMetadata & { /** Provider-supplied identity within this item stream; optional for mixed-version peers. */ callId?: string state: AgentJournalToolCallState + /** Only beside `state: 'failed'`, which builds that predate it read as they always did. Read + * both through `agentJournalToolCallLifecycle`. */ + endedAs?: AgentJournalToolCallEnding output?: AgentJournalBoundedPayload } diff --git a/src/shared/native-chat-tool-activity.ts b/src/shared/native-chat-tool-activity.ts index c873084a2d35..1bb06431fed2 100644 --- a/src/shared/native-chat-tool-activity.ts +++ b/src/shared/native-chat-tool-activity.ts @@ -19,7 +19,10 @@ export const NATIVE_CHAT_TOOL_ACTIVITY_COPY = { * detail. Count-agnostic wording so one entry serves any number. */ failedCount: '{{value0}} failed', /** Spoken form of the same mark — `1 failed` alone does not say failed what. */ - failedCallsLabel: 'Failed tool calls: {{value0}}' + failedCallsLabel: 'Failed tool calls: {{value0}}', + /** Calls a stop cut short, counted apart from failures in the same quiet mark. */ + interruptedCount: '{{value0}} interrupted', + interruptedCallsLabel: 'Interrupted tool calls: {{value0}}' } as const /** Tools whose call is a shell command, so the row reads as terminal activity diff --git a/src/shared/native-chat-tool-run-outcome.test.ts b/src/shared/native-chat-tool-run-outcome.test.ts index 42ca566f95ab..10337ab2d723 100644 --- a/src/shared/native-chat-tool-run-outcome.test.ts +++ b/src/shared/native-chat-tool-run-outcome.test.ts @@ -10,10 +10,21 @@ function result(output: string, isError?: boolean): NativeChatBlock { return { type: 'tool-result', output, isError } } +function interrupted(command: string): NativeChatBlock { + return { + type: 'tool-call', + name: 'shell', + input: { command }, + state: 'failed', + endedAs: 'interrupted' + } +} + describe('nativeChatToolRunOutcome', () => { it('counts a provider failure verdict', () => { expect(nativeChatToolRunOutcome([call('a', 'failed'), result('exit 1', true)], {})).toEqual({ failedCallCount: 1, + interruptedCallCount: 0, succeeded: false }) }) @@ -21,6 +32,7 @@ describe('nativeChatToolRunOutcome', () => { it('counts an error result on a lane that writes no lifecycle state', () => { expect(nativeChatToolRunOutcome([call('a'), result('exit 1', true)], {})).toEqual({ failedCallCount: 1, + interruptedCallCount: 0, succeeded: false }) }) @@ -104,4 +116,30 @@ describe('nativeChatToolRunOutcome', () => { ).succeeded ).toBe(false) }) + + it('counts a call a stop cut short apart from failures, and never as a success', () => { + expect( + nativeChatToolRunOutcome( + [call('a', 'completed'), result('ok'), interrupted('b'), result('partial')], + {} + ) + ).toEqual({ failedCallCount: 0, interruptedCallCount: 1, succeeded: false }) + }) + + it('counts a failure and an interruption in one run separately', () => { + expect( + nativeChatToolRunOutcome([call('a', 'failed'), result('exit 1', true), interrupted('b')], {}) + ).toEqual({ failedCallCount: 1, interruptedCallCount: 1, succeeded: false }) + }) + + it('reads an ending it cannot place as the failed state beside it', () => { + // A value a newer build may persist; the schema keeps the field open. + const block: NativeChatBlock = JSON.parse( + '{"type":"tool-call","name":"shell","input":{},"state":"failed","endedAs":"declined"}' + ) + expect(nativeChatToolRunOutcome([block], {})).toMatchObject({ + failedCallCount: 1, + interruptedCallCount: 0 + }) + }) }) diff --git a/src/shared/native-chat-tool-run-outcome.ts b/src/shared/native-chat-tool-run-outcome.ts index 2f255b09b8f6..6839edb2d1c9 100644 --- a/src/shared/native-chat-tool-run-outcome.ts +++ b/src/shared/native-chat-tool-run-outcome.ts @@ -1,5 +1,5 @@ -// A run of tool calls → the two facts its collapsed header may state: whether -// the run succeeded, and how many of its calls did not. +// A run of tool calls → the facts its collapsed header may state: whether the +// run succeeded, how many of its calls failed, and how many were cut short. // // Shared, and separate from the live-activity derivation, because success is a // claim the header makes on its own. "Nothing is running" is not that claim: @@ -7,16 +7,19 @@ // other marks a failed run done and leaves the failure to be found by expanding // it. Success must be stated, which is what `nativeChatToolRunOutcome` does. +import { agentJournalToolCallLifecycle } from './agent-journal-tool-call-lifecycle' import { selectActiveToolCall } from './native-chat-tool-activity' import type { NativeChatBlock } from './native-chat-types' export type NativeChatToolRunOutcome = { failedCallCount: number + /** Calls a stop or the session's end cut short: neither a failure nor a success. */ + interruptedCallCount: number succeeded: boolean } -/** Whether the run may be marked done: settled, nothing failed, nothing still - * running. The running test is repeated after `selectActiveToolCall` on +/** Whether the run may be marked done: settled, nothing failed or cut short, + * nothing still running. The running test is repeated after `selectActiveToolCall` on * purpose — that one reports no active call once the turn is known to be over, * and an item still running cannot inherit completion from its turn. * @@ -28,12 +31,15 @@ export function nativeChatToolRunOutcome( { activeTurnIsWorking }: { activeTurnIsWorking?: boolean } ): NativeChatToolRunOutcome { let failedStateCount = 0 + let interruptedCallCount = 0 let errorResultCount = 0 let hasRunningCall = false for (const block of blocks) { if (block.type === 'tool-call') { - failedStateCount += block.state === 'failed' ? 1 : 0 - hasRunningCall ||= block.state === 'running' + const lifecycle = agentJournalToolCallLifecycle(block) + failedStateCount += lifecycle === 'failed' ? 1 : 0 + interruptedCallCount += lifecycle === 'interrupted' ? 1 : 0 + hasRunningCall ||= lifecycle === 'running' } else if (block.type === 'tool-result') { errorResultCount += block.isError === true ? 1 : 0 } @@ -42,9 +48,11 @@ export function nativeChatToolRunOutcome( const failedCallCount = Math.max(failedStateCount, errorResultCount) return { failedCallCount, + interruptedCallCount, succeeded: selectActiveToolCall(blocks, { activeTurnIsWorking }) === null && !hasRunningCall && - failedCallCount === 0 + failedCallCount === 0 && + interruptedCallCount === 0 } } diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index db7b7024cdc1..60a81a15e9b7 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -14,7 +14,9 @@ import type { AgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalMessageSendMode, AgentJournalPosition, - AgentJournalProducerLinkage + AgentJournalProducerLinkage, + AgentJournalToolCallEnding, + AgentJournalToolCallState } from './agent-session-journal-types' import type { AgentType } from './agent-status-types' import type { NativeChatToolMetadata } from './native-chat-tool-identity' @@ -70,7 +72,9 @@ export type NativeChatToolCallBlock = NativeChatToolMetadata & { /** Provider-supplied identity within this item stream; absent on legacy transcripts and peers. */ callId?: string /** Provider lifecycle when the structured app-server path can supply it. */ - state?: 'running' | 'completed' | 'failed' + state?: AgentJournalToolCallState + /** See the journal tool-call item: read with `state` through `agentJournalToolCallLifecycle`. */ + endedAs?: AgentJournalToolCallEnding } /** One resolved hunk from a provider's edit result, carrying true file ranges. */ diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 84eaac6301cd..4865b6ebe3a8 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization' import { agentSessionFailureWords } from './agent-session-failure-words' import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import { AgentJournalRenderItemSchema } from './agent-session-journal-schemas' import { parsePaneKey } from './stable-pane-id' import { activeStructuredAgentSessionTurnId, @@ -141,6 +142,24 @@ describe('structured agent session status projection', () => { ]) }) + it('carries a cut-short call through the schema, and its partial output is not an error', () => { + const body = { + kind: 'tool-call' as const, + name: 'shell', + callId: 'call-sleep', + input: { command: 'sleep 20' }, + state: 'failed' as const, + endedAs: 'interrupted' as const, + output: { head: 'partial', digest: 'd', byteLength: 7, truncated: false } + } + const parsed = AgentJournalRenderItemSchema.parse(item('sleep', 1, body)) + expect(parsed.body).toMatchObject({ state: 'failed', endedAs: 'interrupted' }) + expect(projectStructuredItemToNativeChat(item('sleep', 1, body))?.blocks).toEqual([ + expect.objectContaining({ type: 'tool-call', state: 'failed', endedAs: 'interrupted' }), + { type: 'tool-result', output: 'partial', isError: false, callId: 'call-sleep' } + ]) + }) + it('projects running, attention, and completed lifecycle states', () => { const running = item('running', 1, { kind: 'status', diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 0c2173c0a2dc..bb352d7549c9 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -10,6 +10,7 @@ import { type AgentJournalSubmission } from './agent-session-journal-types' import { agentTurnVerdict, type AgentTurnOutcome } from './agent-turn-outcome' +import { agentJournalToolCallLifecycle } from './agent-journal-tool-call-lifecycle' import { agentJournalLinkageFields } from './agent-session-journal-producer' import { structuredAgentSessionStatusBlock } from './structured-agent-session-status-block' import { agentJournalItemRowOrigin } from './agent-session-journal-position' @@ -89,7 +90,8 @@ function itemBlocks(item: AgentJournalRenderItem): { { type: 'tool-result' as const, output: boundedText(body.output), - isError: body.state === 'failed', + // Output a call left when it was cut short is not an error it reported. + isError: agentJournalToolCallLifecycle(body) === 'failed', // The call and its output are one journal row, so the result names its call. ...(body.callId !== undefined ? { callId: body.callId } : {}) } diff --git a/src/shared/structured-agent-session-tool-call-block.ts b/src/shared/structured-agent-session-tool-call-block.ts index d3b43117d1ad..b78ff9579b2a 100644 --- a/src/shared/structured-agent-session-tool-call-block.ts +++ b/src/shared/structured-agent-session-tool-call-block.ts @@ -37,6 +37,7 @@ export function structuredAgentSessionToolCallBlock( name: action.name, input: action.input, state: action.state, + ...(action.endedAs !== undefined ? { endedAs: action.endedAs } : {}), ...(action.callId !== undefined ? { callId: action.callId } : {}), ...(action.mcpIdentity !== undefined ? { mcpIdentity: action.mcpIdentity } : {}), ...(action.exitCode !== undefined ? { exitCode: action.exitCode } : {}), diff --git a/tests/e2e/cross-version-wire/agent-session-interrupted-tool-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-interrupted-tool-downgrade.unit.test.ts new file mode 100644 index 000000000000..ad00d1ec1a00 --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-interrupted-tool-downgrade.unit.test.ts @@ -0,0 +1,132 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from 'vitest' +import { interruptedAgentJournalToolCall } from '../../../src/shared/agent-journal-tool-call-lifecycle' +import type { AgentJournalItemIdentity } from '../../../src/shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +// A release that predates a tool call's `endedAs`. Pinned, not the newest tag: what it must do is +// read the cut-short call as failed, which no later release will contradict. +const BASELINE_REF = 'v1.4.218' + +const SESSION = 'session-interrupted-tool' + +function call(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function releaseExport(module: Record, name: string): T { + const value = module[name] + if (value === undefined) { + throw new Error(`the pinned release exports no ${name}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an export the pinned release has; each caller names the shape it uses, and a changed one fails the test. + return value as T +} + +type OldRenderItem = { itemId: string; body: { kind: string; [field: string]: unknown } } +type OldMessage = { blocks: unknown[] } | null +type OldRunOutcome = ( + blocks: readonly unknown[], + options: { activeTurnIsWorking?: boolean } +) => { failedCallCount: number; succeeded: boolean } + +// Loads a real old build, including cold extraction and transforms. +test('an older host and client read a call a stop cut short as the failure they always showed', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-interrupted-tool-downgrade-')) + const journals = createTrackedJournalOpener() + try { + // This build: a call a stop cut short, with the partial output it left. + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + stateDirectory: directory, + now: () => 1_000 + }) + const cutShort = interruptedAgentJournalToolCall({ + kind: 'tool-call', + name: 'shell', + input: { command: 'sleep 20' }, + state: 'running', + output: { head: 'partial', byteLength: 7, digest: 'd', truncated: false } + }) + await journal.appendItem(call(0), cutShort, { fence: 1, turnScope: { kind: 'thread' } }) + const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + const rows: JournalRow[] = since.rows + const current = journal.snapshot().items + expect(current[0]?.body).toMatchObject({ state: 'failed', endedAs: 'interrupted' }) + + const checkout = await materializeReleaseCheckout(BASELINE_REF) + const [reducer, schemas, projection, outcome] = await Promise.all( + [ + 'src/main/native-chat/agent-session-journal/journal-reducer.ts', + 'src/shared/agent-session-journal-schemas.ts', + 'src/shared/structured-agent-session-projection.ts', + 'src/shared/native-chat-tool-run-outcome.ts' + ].map((path) => importReleaseCheckoutModule(checkout, path)) + ) + const createState = releaseExport<(sessionId: string, epoch: string) => unknown>( + reducer, + 'createJournalReducerState' + ) + const applyRow = releaseExport<(state: unknown, row: JournalRow) => void>( + reducer, + 'applyJournalRow' + ) + const render = releaseExport<(state: unknown) => { items: OldRenderItem[] }>( + reducer, + 'renderJournalState' + ) + const renderItemSchema = releaseExport<{ parse: (value: unknown) => OldRenderItem }>( + schemas, + 'AgentJournalRenderItemSchema' + ) + const project = releaseExport<(item: unknown) => OldMessage>( + projection, + 'projectStructuredItemToNativeChat' + ) + const runOutcome = releaseExport(outcome, 'nativeChatToolRunOutcome') + const settled = { activeTurnIsWorking: false } + + // The older host, after a downgrade, folds the row it never wrote as a failed call. + const state = createState(SESSION, journal.epoch) + for (const row of rows) { + applyRow(state, row) + } + const [folded] = render(state).items + expect(folded?.body).toMatchObject({ kind: 'tool-call', state: 'failed' }) + expect(runOutcome(project(folded)?.blocks ?? [], settled)).toEqual({ + failedCallCount: 1, + succeeded: false + }) + + // The older client reads this host's render item the same way. + const [read] = current.map((item) => renderItemSchema.parse(item)) + expect(runOutcome(project(read)?.blocks ?? [], settled)).toEqual({ + failedCallCount: 1, + succeeded: false + }) + + // Why the fact rides beside `failed` rather than replacing it: that build calls an unknown + // state a success. + const asNewState = renderItemSchema.parse({ + ...current[0], + body: { ...cutShort, state: 'interrupted', endedAs: undefined } + }) + expect(runOutcome(project(asNewState)?.blocks ?? [], settled).succeeded).toBe(true) + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } +}) From b012f23b0995081ce2d072b28f2bd38dc695eba8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:41:29 -0700 Subject: [PATCH 060/269] Assert a completed Codex turn leaves its unclosed call plainly failed --- .../codex-structured-journal-translation-settlement.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/codex/codex-structured-journal-translation-settlement.test.ts b/src/main/codex/codex-structured-journal-translation-settlement.test.ts index 400a4dd6bfd0..6c08f01bc49b 100644 --- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts +++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts @@ -566,6 +566,8 @@ describe('codex journal translation', () => { ] } ]) + // A completed turn proves no interruption. + expect(batches[0]?.mutations[0]).not.toHaveProperty('body.endedAs') }) it('cuts short an active tool when Codex reports its turn interrupted', () => { From 9c77b1a230788c7008fc43bc4efc45cc5832f952 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:41:48 -0700 Subject: [PATCH 061/269] Assert an unverifiable restart settlement leaves its running call plainly failed --- .../structured-agent-session-dead-generation-settlement.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 09afb2ee5f21..be44cdf93b70 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -122,6 +122,8 @@ describe('dead structured-session generation settlement', () => { ]) ) expect(snapshot.items.some((item) => item.body.kind === 'status')).toBe(false) + const tool = snapshot.items.find((item) => item.body.kind === 'tool-call') + expect(tool?.body).not.toHaveProperty('endedAs') }) it('adds one actionable outcome for observed active-work failure and is idempotent', async () => { From 4cc29b0dd49984cf76b181f961ca9d6ef9285147 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:58:37 -0700 Subject: [PATCH 062/269] fix(native-chat): act on restart offers only for agents the calling client can show A paired client too old to show an agent's chat was listed only the offers it could show, but dismissing or continuing all reached every offer on the host, and a named continuation answered with the host's whole remaining inventory. The client's audience now goes to the host with every restart operation: only offers it sees are reserved, dismissed or returned. Without an audience (this host's own process, or a client that shows every agent) nothing changes. --- ...red-agent-session-restart-audience.test.ts | 79 ++++++++++ ...ed-agent-session-restart-failure-ledger.ts | 18 ++- ...tured-agent-session-restart-resume-host.ts | 66 +++++---- ...ctured-agent-session-restart-resume-set.ts | 12 ++ .../agent-session-recovery-capsule.test.ts | 18 +++ .../runtime/agent-session-recovery-capsule.ts | 12 +- .../structured-agent-session-policy.ts | 19 ++- ...tured-agent-session-restart-resume.test.ts | 138 ++++++++++++++++++ ...structured-agent-session-restart-resume.ts | 30 ++-- 9 files changed, 333 insertions(+), 59 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts new file mode 100644 index 000000000000..f468d0bd12ee --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts @@ -0,0 +1,79 @@ +// A client too old to show an agent's chat is handed an audience without that agent. Every restart +// operation it calls leaves that agent's offers and failures alone, and never names them back. +// The chat here is a Codex one; an audience without Codex stands in for any agent the client +// cannot show. + +import { afterEach, expect, it, vi } from 'vitest' +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION +} from './structured-agent-session-host-test-data' + +afterEach(() => vi.restoreAllMocks()) + +const cannotShowCodex = (agent: string) => agent !== 'codex' +const showsCodex = (agent: string) => agent === 'codex' + +function offersIn(root: string) { + return new AgentSessionRecoveryCapsule(root).list(NOW) +} + +it('neither lists nor dismisses an offer the caller cannot show', async () => { + const { host, root } = await interruptedRestart() + + expect(await host.restartResume.list(cannotShowCodex)).toEqual([]) + expect(await host.restartResume.dismiss(undefined, cannotShowCodex)).toBe(0) + expect(await host.restartResume.dismiss([SESSION], cannotShowCodex)).toBe(0) + expect(await offersIn(root)).toHaveLength(1) + expect(await host.restartResume.list()).toHaveLength(1) + + // A caller that sees it dismisses it by dismissing everything it was shown. + expect(await host.restartResume.dismiss(undefined, showsCodex)).toBe(1) + expect(await offersIn(root)).toEqual([]) +}) + +it('neither continues nor reserves an offer the caller cannot show, named or not', async () => { + const { host, root, acquire, dispatch } = await interruptedRestart() + + for (const named of [undefined, [SESSION]]) { + expect(await host.restartResume.continueAfterRestart(named, 'modal', cannotShowCodex)).toEqual({ + resumed: [], + continued: [], + sessions: [], + failed: [] + }) + } + expect(acquire).not.toHaveBeenCalled() + expect(dispatch).not.toHaveBeenCalled() + expect(await offersIn(root)).toHaveLength(1) + + const result = await host.restartResume.continueAfterRestart(undefined, 'modal', showsCodex) + expect(result.continued).toMatchObject([{ sessionId: SESSION, outcome: 'continued' }]) +}) + +it('keeps a failure the caller cannot show, and leaves it out of an action reply', async () => { + const { host, acquire } = await interruptedRestart() + await host.restartResume.list() + acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(await host.restartResume.listFailures()).toHaveLength(1) + + expect(await host.restartResume.listFailures(cannotShowCodex)).toEqual([]) + // Naming a chat it can act on still answers with only what it can show. + const reply = await host.restartResume.continueAfterRestart( + ['another-session'], + 'modal', + cannotShowCodex + ) + expect(reply).toMatchObject({ sessions: [], failed: [] }) + expect( + (await host.restartResume.continueAfterRestart(['another-session'], 'modal')).failed + ).toHaveLength(1) + + expect(await host.restartResume.dismiss(undefined, cannotShowCodex)).toBe(0) + expect(await host.restartResume.listFailures()).toHaveLength(1) + expect(await host.restartResume.dismiss(undefined, showsCodex)).toBe(1) + expect(await host.restartResume.listFailures()).toEqual([]) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index 63722d1737cc..0b217d58303b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts @@ -23,6 +23,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-a import { adapterSupportsRecord } from './structured-agent-session-provider-support' import type { StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' import type { + StructuredAgentSessionRestartAudience, StructuredAgentSessionResumeCandidate, StructuredAgentSessionResumeFailure } from './structured-agent-session-restart-resume-set' @@ -58,10 +59,12 @@ export type StructuredAgentSessionRestartFailureLedger = { failureReason: (sessionId: string) => string } ) => Promise - /** Named sessions forget their offer or failure; unnamed, every durable record goes. */ + /** Named sessions forget their offer or failure; unnamed, every durable record goes. With an + * audience, only records of agents it sees go, and an unnamed dismissal is no fence. */ dismiss: ( sessionIds: readonly string[] | undefined, - beforeClearAll: () => void + beforeClearAll: () => void, + audience?: StructuredAgentSessionRestartAudience ) => Promise } @@ -211,8 +214,17 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { read, list, settle, - dismiss: (sessionIds, beforeClearAll) => + dismiss: (sessionIds, beforeClearAll, audience) => deps.enqueue(async () => { + if (audience) { + // Decided under the capsule lock. A record whose chat this host cannot read names no + // agent the audience was shown, so it stays. + const hidden = (marker: AgentSessionResumeMarker) => { + const record = deps.getRecord(marker.sessionId) + return record === null || !audience(record.provider) + } + return (await deps.capsule?.dismiss(sessionIds ?? 'all', deps.now(), hidden)) ?? 0 + } if (sessionIds !== undefined) { return (await deps.capsule?.dismiss(sessionIds, deps.now())) ?? 0 } 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 ef8481e2b1f5..3e30b0baf92c 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 @@ -21,9 +21,11 @@ import { } from './structured-agent-session-restart-failure-ledger' import { createStructuredAgentSessionRestartOperationQueue } from './structured-agent-session-restart-operation-queue' import { createStructuredAgentSessionRestartOfferRecords } from './structured-agent-session-restart-offer-records' -import type { - StructuredAgentSessionResumeCandidate, - StructuredAgentSessionResumeFailure +import { + restartRowsFor, + type StructuredAgentSessionRestartAudience, + type StructuredAgentSessionResumeCandidate, + type StructuredAgentSessionResumeFailure } from './structured-agent-session-restart-resume-set' import { resumeStructuredAgentSessionsFromRestart, @@ -55,20 +57,30 @@ export type StructuredAgentSessionRestartResume = { captureBeforeStop: (sessionId: string) => void confirmStopped: (sessionId: string) => void recordMarkers: () => Promise - list: () => Promise + list: ( + audience?: StructuredAgentSessionRestartAudience + ) => Promise /** Offers already acted on whose agent did not carry on. Read-only; nothing here is spent. */ - listFailures: () => Promise + listFailures: ( + audience?: StructuredAgentSessionRestartAudience + ) => Promise + /** Unnamed, continues every offer the audience sees; named, only those of them. */ continueAfterRestart: ( sessionIds: readonly string[] | undefined, - owner: string + owner: string, + audience?: StructuredAgentSessionRestartAudience ) => Promise<{ resumed: StructuredAgentSessionResumeOutcome[] continued: StructuredAgentSessionContinuationOutcome[] sessions?: StructuredAgentSessionResumeCandidate[] failed?: StructuredAgentSessionResumeFailure[] }> - /** Named sessions forget their offer or failure; unnamed, every durable record goes. */ - dismiss: (sessionIds?: readonly string[]) => Promise + /** Named sessions forget their offer or failure; unnamed, every durable record goes. 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 } @@ -132,15 +144,19 @@ export function createStructuredAgentSessionRestartResume( enqueue: enqueueRecoveryOperation }) - const list = async (): Promise => { + const list = async ( + audience?: StructuredAgentSessionRestartAudience + ): Promise => { const markers = await readMarkers() await revealMarkers(markers) // A live chat remains an offer. The user may have opened it to inspect the context and still // explicitly choose whether Orca should ask the agent to continue. const { candidates, superseded } = derive(markers, 'may-be-held') retireSuperseded(superseded) - return candidates + return restartRowsFor(candidates, audience) } + const listFailures = async (audience?: StructuredAgentSessionRestartAudience) => + restartRowsFor(await failures.list(), audience) const continuationHost: StructuredAgentSessionContinuationHost = { ...surfaces, @@ -158,6 +174,7 @@ export function createStructuredAgentSessionRestartResume( const run = async ( sessionIds: readonly string[] | undefined, owner: string, + audience: StructuredAgentSessionRestartAudience | undefined, continueOne: (marker: AgentSessionResumeMarker, continuationId: string) => Promise ) => { // An explicit action supersedes teardown witnesses captured by this host. The durable mutation @@ -168,7 +185,10 @@ export function createStructuredAgentSessionRestartResume( const requested = new Set(sessionIds ?? markers.map((marker) => marker.sessionId)) const derived = derive(markers, 'may-be-held') retireSuperseded(derived.superseded) - const eligible = derived.candidates.filter((candidate) => requested.has(candidate.sessionId)) + // Only what the caller was shown is reserved; a named offer it cannot see is not. + const eligible = restartRowsFor(derived.candidates, audience).filter((candidate) => + requested.has(candidate.sessionId) + ) if (eligible.length === 0) { return null } @@ -222,20 +242,16 @@ export function createStructuredAgentSessionRestartResume( } } - const continueAfterRestart = async ( - sessionIds: readonly string[] | undefined, - owner: string - ): Promise<{ - resumed: StructuredAgentSessionResumeOutcome[] - continued: StructuredAgentSessionContinuationOutcome[] - sessions?: StructuredAgentSessionResumeCandidate[] - failed?: StructuredAgentSessionResumeFailure[] - }> => { + const continueAfterRestart: StructuredAgentSessionRestartResume['continueAfterRestart'] = async ( + sessionIds, + owner, + audience + ) => { const continued: StructuredAgentSessionContinuationOutcome[] = [] const verdicts: Promise[] = [] // A chat holds its slot until its agent took the continuation or its start failed, so a batch // never starts more agents at once than the runner allows; the provider's answer comes after. - const action = await run(sessionIds, owner, async (marker, continuationId) => { + const action = await run(sessionIds, owner, audience, async (marker, continuationId) => { const started = await startStructuredAgentSessionContinuation( restartContinuationDeps(continuationHost, marker), marker.sessionId, @@ -286,8 +302,8 @@ export function createStructuredAgentSessionRestartResume( let remainingCandidates: StructuredAgentSessionResumeCandidate[] | undefined let remainingFailures: StructuredAgentSessionResumeFailure[] | undefined try { - remainingCandidates = await list() - remainingFailures = await failures.list() + remainingCandidates = await list(audience) + remainingFailures = await listFailures(audience) } catch { deps.logger.warn('refreshing restart offers after an action failed', { scope: 'restart-offer-refresh' @@ -307,10 +323,10 @@ export function createStructuredAgentSessionRestartResume( confirmStopped: witnesses.stopped, recordMarkers: witnesses.record, list, - listFailures: failures.list, + listFailures, // Do not let a teardown witness already captured in this host republish after explicit // dismissal. A later capture is a new interruption and may create a fresh offer normally. - dismiss: (sessionIds) => failures.dismiss(sessionIds, witnesses.clear), + dismiss: (sessionIds, audience) => failures.dismiss(sessionIds, witnesses.clear, audience), continueAfterRestart, onAgentStarted: withdrawal.onAgentStarted } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts index 3c91a9732b9b..ec2a082fbc90 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts @@ -65,6 +65,18 @@ export type StructuredAgentSessionResumeFailure = StructuredAgentSessionResumeCa retryable: boolean } +/** The agents whose offers a caller may see and act on; absent, every agent this host runs. A + * client too old to show an agent's chat never lists, resumes, or dismisses that agent's offers. */ +export type StructuredAgentSessionRestartAudience = (agent: string) => boolean + +/** The rows `audience` may see; all of them without one. */ +export function restartRowsFor( + rows: T[], + audience: StructuredAgentSessionRestartAudience | undefined +): T[] { + return audience ? rows.filter((row) => audience(row.agent)) : rows +} + export type StructuredAgentSessionResumableSet = { candidates: StructuredAgentSessionResumeCandidate[] /** Markers the chat has provably moved past, or whose conversation forked. Every ending deletes: diff --git a/src/main/runtime/agent-session-recovery-capsule.test.ts b/src/main/runtime/agent-session-recovery-capsule.test.ts index d70fde57af30..bb3c93a6a989 100644 --- a/src/main/runtime/agent-session-recovery-capsule.test.ts +++ b/src/main/runtime/agent-session-recovery-capsule.test.ts @@ -270,6 +270,24 @@ describe('durable restart offers', () => { expect(await capsule.list(NOW)).toEqual([marker({ sessionId: 'third' }), marker()]) }) + it('forgets every record of any state except the ones kept, without a fence', async () => { + await capsule.record( + [marker(), marker({ sessionId: 'second' }), marker({ sessionId: 'kept' })], + NOW + ) + await fileFailure() + expect(await capsule.listFailed(NOW)).toHaveLength(1) + await capsule.beginResume(['second'], 'operation-b', NOW) + + const keep = (stored: { sessionId: string }) => stored.sessionId === 'kept' + expect(await capsule.dismiss('all', NOW, keep)).toBe(2) + expect(await capsule.list(NOW)).toEqual([marker({ sessionId: 'kept' })]) + expect(await capsule.listFailed(NOW)).toEqual([]) + // Unlike clearAll, a later teardown of a dismissed chat may offer it again. + await capsule.record([marker()], NOW) + expect(await capsule.list(NOW)).toEqual([marker({ sessionId: 'kept' }), marker()]) + }) + // A failure record has no expiry either; it ends only with the user's own actions. it('keeps a months-old failure on record', async () => { await capsule.record([marker()], NOW) diff --git a/src/main/runtime/agent-session-recovery-capsule.ts b/src/main/runtime/agent-session-recovery-capsule.ts index 80ed0bb726e2..7343ab64e8e1 100644 --- a/src/main/runtime/agent-session-recovery-capsule.ts +++ b/src/main/runtime/agent-session-recovery-capsule.ts @@ -199,21 +199,23 @@ export class AgentSessionRecoveryCapsule { }) } - /** Forgets the named sessions whatever their state. Unlike `clearAll`, this is not a fence: a - * later teardown of the same chat may record a fresh offer. */ + /** Forgets the named sessions, or every session, whatever their state. Unlike `clearAll`, this + * is not a fence: a later teardown of the same chat may record a fresh offer. */ dismiss( - sessionIds: readonly string[], + sessionIds: readonly string[] | 'all', now: number, /** A record this answers true for stays: read against the stored marker, under the lock. */ keep: (marker: AgentSessionResumeMarker) => boolean = () => false ): Promise { return withFileTransactionLock(this.filePath, async () => { - const named = new Set(sessionIds) + const named = sessionIds === 'all' ? null : new Set(sessionIds) + const chosen = (marker: AgentSessionResumeMarker) => + (named?.has(marker.sessionId) ?? true) && !keep(marker) const state = await this.readState() const { entries, failed } = normalizeState(state, now) const dismissed = new Set( [...entries, ...failed] - .filter((record) => named.has(record.marker.sessionId) && !keep(record.marker)) + .filter((record) => chosen(record.marker)) .map((record) => record.marker.sessionId) ) if (dismissed.size > 0) { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts index c6403d609488..8088d2621896 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -23,19 +23,18 @@ export function supportsStructuredAgentSessions( ) } -/** Whether a published row naming `agent` is one this client reads. Every client knows Claude and - * Codex; any other agent reaches only a client that renders the host's registered agents. */ -export function clientReadsStructuredAgent( - context: Pick, - agent: string -): boolean { - return ( - isAgentSessionHandleProvider(agent) || - context.clientKind === undefined || +/** Which agents' rows this client reads, or undefined when it reads every agent the host runs. + * Every client knows Claude and Codex; any other agent reaches only a client that renders the + * host's registered agents. */ +export function structuredAgentsReadBy( + context: Pick +): ((agent: string) => boolean) | undefined { + return context.clientKind === undefined || context.clientCapabilities?.includes( STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY ) === true - ) + ? undefined + : isAgentSessionHandleProvider } /** diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts new file mode 100644 index 000000000000..88e32f076751 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts @@ -0,0 +1,138 @@ +// The restart-offer methods act for the calling client: a paired client too old to show an agent +// is never listed, resumed, dismissed, or answered that agent's offers. The host stub filters by the +// audience it is handed, as the real host does (structured-agent-session-restart-audience.test.ts). + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + restartRowsFor, + type StructuredAgentSessionRestartAudience +} from '../../../native-chat/agent-session-wire/structured-agent-session-restart-resume-set' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + call, + clearStructuredHostStub, + hostStub, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' + +afterEach(clearStructuredHostStub) + +const CLAUDE_OFFER = { sessionId: 'claude-session', agent: 'claude' } +const GROK_OFFER = { sessionId: 'grok-session', agent: 'grok' } + +const OLD_CLIENT = STRUCTURED_CLIENT +const NEW_CLIENT = { + ...STRUCTURED_CLIENT, + clientCapabilities: [ + ...STRUCTURED_CLIENT.clientCapabilities, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ] +} + +/** A host holding a Claude and a Grok offer, plus a recorded Grok failure. */ +function installRestartHost() { + let offers = [CLAUDE_OFFER, GROK_OFFER] + const failures = [{ ...GROK_OFFER, reason: 'agent_session_resume_refused' }] + const list = async (audience?: StructuredAgentSessionRestartAudience) => + restartRowsFor(offers, audience) + const listFailures = async (audience?: StructuredAgentSessionRestartAudience) => + restartRowsFor(failures, audience) + const restartResume = { + list: vi.fn(list), + listFailures: vi.fn(listFailures), + dismiss: vi.fn( + async (sessionIds?: readonly string[], audience?: StructuredAgentSessionRestartAudience) => { + const gone = restartRowsFor(offers, audience).filter( + (offer) => sessionIds?.includes(offer.sessionId) ?? true + ) + offers = offers.filter((offer) => !gone.includes(offer)) + return gone.length + } + ), + continueAfterRestart: vi.fn( + async ( + _sessionIds: readonly string[] | undefined, + _owner: string, + audience?: StructuredAgentSessionRestartAudience + ) => ({ + resumed: [], + continued: [], + sessions: await list(audience), + failed: await listFailures(audience) + }) + ) + } + setStructuredAgentSessionHost(Object.assign(hostStub(), { restartResume })) + return { restartResume, offers: () => offers } +} + +describe("a paired client too old to show the host's other agents", () => { + it('lists only the offers it can show', async () => { + installRestartHost() + expect(await call('agentSession.restartResumable', {}, OLD_CLIENT)).toMatchObject({ + ok: true, + result: { sessions: [CLAUDE_OFFER], failed: [] } + }) + }) + + it('dismisses only the offers it was shown when it dismisses all', async () => { + const host = installRestartHost() + expect(await call('agentSession.restartResumableDismiss', {}, OLD_CLIENT)).toMatchObject({ + ok: true, + result: { dismissed: 1, sessions: [], failed: [] } + }) + expect(host.offers()).toEqual([GROK_OFFER]) + }) + + it('cannot dismiss an offer it was not shown by naming it', async () => { + const host = installRestartHost() + expect( + await call( + 'agentSession.restartResumableDismiss', + { sessionIds: [GROK_OFFER.sessionId] }, + OLD_CLIENT + ) + ).toMatchObject({ ok: true, result: { dismissed: 0, sessions: [CLAUDE_OFFER], failed: [] } }) + expect(host.offers()).toEqual([CLAUDE_OFFER, GROK_OFFER]) + }) + + it('hands the host its audience when it continues all, so hidden offers are not run', async () => { + const host = installRestartHost() + await call('agentSession.restartContinue', {}, OLD_CLIENT) + const audience = host.restartResume.continueAfterRestart.mock.lastCall?.[2] + expect(host.restartResume.continueAfterRestart.mock.lastCall?.[0]).toBeUndefined() + expect(audience?.('claude')).toBe(true) + expect(audience?.('codex')).toBe(true) + expect(audience?.('grok')).toBe(false) + }) + + it('is not answered the hidden offers or failures after a named continuation', async () => { + installRestartHost() + expect( + await call( + 'agentSession.restartContinue', + { sessionIds: [CLAUDE_OFFER.sessionId] }, + OLD_CLIENT + ) + ).toMatchObject({ ok: true, result: { sessions: [CLAUDE_OFFER], failed: [] } }) + }) +}) + +describe.each([ + ["a paired client that shows the host's agents", NEW_CLIENT], + ["the host's own process", undefined] +])('%s', (_label, client) => { + it('acts on every offer', async () => { + const host = installRestartHost() + expect(await call('agentSession.restartResumable', {}, client)).toMatchObject({ + ok: true, + result: { sessions: [CLAUDE_OFFER, GROK_OFFER], failed: [{ agent: 'grok' }] } + }) + await call('agentSession.restartContinue', {}, client) + expect(host.restartResume.continueAfterRestart.mock.lastCall?.[2]).toBeUndefined() + await call('agentSession.restartResumableDismiss', {}, client) + expect(host.restartResume.dismiss.mock.lastCall?.[1]).toBeUndefined() + expect(host.offers()).toEqual([]) + }) +}) 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 86f3e3f59ec6..0155ee04e53d 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 @@ -3,7 +3,7 @@ // Each method reaches for records on disk this process may not have opened yet, so each builds the // host the way hold and reveal do. Listing takes nothing live and spends no live offer; acting goes // through the host's single resume path, which re-derives eligibility rather than trusting the ids -// it is given. +// it is given. Every method acts only on offers for agents the calling client can show. import { defineMethod } from '../core' import { @@ -12,13 +12,7 @@ import { structuredCallerFor } from './structured-agent-session-gate' import { RestartResumableParams, RestartResumeParams } from './structured-agent-session-schemas' -import { clientReadsStructuredAgent } from './structured-agent-session-policy' -import type { RpcContext } from '../core' - -/** Offers for agents this client cannot show stay on the host for a client that can. */ -function readableOffers(ctx: RpcContext, offers: readonly T[]): T[] { - return offers.filter((offer) => clientReadsStructuredAgent(ctx, offer.agent)) -} +import { structuredAgentsReadBy } from './structured-agent-session-policy' export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ defineMethod({ @@ -27,10 +21,11 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ handler: async (_params, ctx) => { await ensureStructuredHostInstalled(ctx) const host = requireStructuredHost(ctx) + const audience = structuredAgentsReadBy(ctx) return { - sessions: readableOffers(ctx, await host.restartResume.list()), + sessions: await host.restartResume.list(audience), // Acted-on offers whose agent did not carry on. Optional on the wire; older clients ignore it. - failed: readableOffers(ctx, await host.restartResume.listFailures()) + failed: await host.restartResume.listFailures(audience) } } }), @@ -42,16 +37,18 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ handler: async (params, ctx) => { await ensureStructuredHostInstalled(ctx) const host = requireStructuredHost(ctx) - const dismissed = await host.restartResume.dismiss(params.sessionIds) + // Offers this client was never shown stay for a client that can show them. + const audience = structuredAgentsReadBy(ctx) + const dismissed = await host.restartResume.dismiss(params.sessionIds, audience) if (params.sessionIds === undefined) { - // clearAll is the authoritative mutation: it removes pending and in-flight records, so a - // second read would only add a new failure point after the user's explicit dismissal. + // The dismissal removed every pending and in-flight record this client sees, so a second + // read would only add a new failure point after the user's explicit dismissal. return { dismissed, sessions: [], failed: [] } } return { dismissed, - sessions: readableOffers(ctx, await host.restartResume.list()), - failed: readableOffers(ctx, await host.restartResume.listFailures()) + sessions: await host.restartResume.list(audience), + failed: await host.restartResume.listFailures(audience) } } }), @@ -66,7 +63,8 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ const host = requireStructuredHost(ctx) return host.restartResume.continueAfterRestart( params.sessionIds, - structuredCallerFor(ctx).callerKey + structuredCallerFor(ctx).callerKey, + structuredAgentsReadBy(ctx) ) } }), From 1c6ba3ced1e732afc37b10b21879e698e8899393 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:59:44 -0700 Subject: [PATCH 063/269] test(native-chat): read an agent-registering baseline's storage on its own terms The registered-agents downgrade test assumed its baseline release predates registered agents: it expected the saved-tab parser to erase an unknown agent and called the record reader without the agents list. Once a release with this change becomes the baseline, both break. The expectations now follow what the baseline host advertises, and an agent-registering baseline is handed its own Claude and Codex storage. --- ...oss-version-registered-agents.unit.test.ts | 53 +++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts index 997ae2ea1469..2a2f670465c4 100644 --- a/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts @@ -12,6 +12,8 @@ // // The old side's lists are read from its checkout and the capability removed from them, so this // stays exercised after a release ships the capability (docs/reference/remote-wire-compatibility.md). +// What the old side stores is its own code, so those expectations follow what the baseline host +// advertises, never its version. import { beforeAll, describe, expect, it } from 'vitest' import { z } from 'zod' @@ -60,7 +62,10 @@ type Baseline = { ) => RuntimeMobileSessionTabsSnapshot parseWorkspaceSession: (raw: unknown) => { ok: boolean; value?: unknown } isStructuredTab: (tab: unknown) => boolean - isPersistedAgentSessionRecord: (value: unknown) => boolean + /** Builds that register agents also take the agents a record may name. */ + isPersistedAgentSessionRecord: (value: unknown, agents?: unknown) => boolean + /** What the baseline's own record store admits from Claude and Codex, when it takes a list. */ + claudeAndCodexStoredAgents: unknown } let baseline: Baseline @@ -92,17 +97,40 @@ beforeAll(async () => { importReleaseCheckoutModule(checkout, 'src/shared/agent-session-record.ts') ]) current = await loadAgentSessionWireBuild(WORKING_TREE) + const wire = await loadAgentSessionWireBuild(ref) + const storedAgents = registersAgents(wire) + ? await importReleaseCheckoutModule( + checkout, + 'src/shared/agent-session-stored-agent.test-fixture.ts' + ) + : null baseline = { ref, - wire: await loadAgentSessionWireBuild(ref), + wire, desktopClientCapabilities: member(capabilities, 'DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES'), projectSessionTabAgentStatus: member(projection, 'projectSessionTabAgentStatus'), parseWorkspaceSession: member(schema, 'parseWorkspaceSession'), isStructuredTab: member(tabs, 'isStructuredTab'), - isPersistedAgentSessionRecord: member(record, 'isPersistedAgentSessionRecord') + isPersistedAgentSessionRecord: member(record, 'isPersistedAgentSessionRecord'), + claudeAndCodexStoredAgents: storedAgents + ? member(storedAgents, 'CLAUDE_AND_CODEX_STORED_AGENTS') + : undefined } }, SUITE_TIMEOUT_MS) +/** Whether a build registers agents beyond Claude and Codex. From then on its saved tabs keep any + * agent's id, and its record store admits exactly the agents its runtime registered. */ +function registersAgents(build: AgentSessionWireBuild): boolean { + return build.capabilities.includes(STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY) +} + +function baselineReadsRecord(record: AgentSessionRecord): boolean { + return baseline.isPersistedAgentSessionRecord( + encodeAgentSessionRecord(record), + baseline.claudeAndCodexStoredAgents + ) +} + /** What a desktop too old to render the host's agents advertises: its own list without the * capability, so the day a release ships it this still describes a client that predates it. */ function oldDesktopClientCapabilities(): string[] { @@ -245,9 +273,15 @@ describe('a structured agent beyond Claude and Codex, across versions', () => { expect(parsed.ok).toBe(true) const tabs = unifiedTabsOf(parsed) expect(tabs.map((tab) => tab.id)).toEqual(['chat-claude', 'chat-new-agent', 'chat-codex']) - expect(tabs.map((tab) => tab.agentSessionAgent)).toEqual(['claude', undefined, 'codex']) - // The tab it cannot place stays listed and mounts no chat pane. - expect(tabs.map((tab) => baseline.isStructuredTab(tab))).toEqual([true, false, true]) + if (registersAgents(baseline.wire)) { + // It already keeps any agent's id; whether it mounts that chat is its own client's call. + expect(tabs.map((tab) => tab.agentSessionAgent)).toEqual(['claude', NEW_AGENT, 'codex']) + expect([tabs[0], tabs[2]].map((tab) => baseline.isStructuredTab(tab))).toEqual([true, true]) + } else { + expect(tabs.map((tab) => tab.agentSessionAgent)).toEqual(['claude', undefined, 'codex']) + // The tab it cannot place stays listed and mounts no chat pane. + expect(tabs.map((tab) => baseline.isStructuredTab(tab))).toEqual([true, false, true]) + } // This build reads the same saved session with the agent intact. expect( unifiedTabsOf(parseWorkspaceSession(persistedSession())).map((tab) => tab.agentSessionAgent) @@ -255,9 +289,8 @@ describe('a structured agent beyond Claude and Codex, across versions', () => { }) it("sets aside the new agent's record and reads Claude and Codex records as before", () => { - expect( - baseline.isPersistedAgentSessionRecord(encodeAgentSessionRecord(newAgentRecord())) - ).toBe(false) + // A build that registers agents is given the two it shipped: one without the new agent. + expect(baselineReadsRecord(newAgentRecord())).toBe(false) const claude = agentSessionRecordFixture() const codex: AgentSessionRecord = { ...claude, @@ -269,7 +302,7 @@ describe('a structured agent beyond Claude and Codex, across versions', () => { accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' } } for (const record of [claude, codex]) { - expect(baseline.isPersistedAgentSessionRecord(encodeAgentSessionRecord(record))).toBe(true) + expect(baselineReadsRecord(record)).toBe(true) } }) }) From 23866edfad59e8c08e1fc62c49d8a37aeae7122c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:02:31 -0700 Subject: [PATCH 064/269] Honor explicit Grok task completion without an exit code --- src/main/acp/acp-dialects/grok-background-tasks.ts | 6 ++---- src/main/acp/acp-timeline-background-tasks.test.ts | 5 +++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/main/acp/acp-dialects/grok-background-tasks.ts b/src/main/acp/acp-dialects/grok-background-tasks.ts index 1f354b26f98a..1477529a45a5 100644 --- a/src/main/acp/acp-dialects/grok-background-tasks.ts +++ b/src/main/acp/acp-dialects/grok-background-tasks.ts @@ -102,10 +102,8 @@ export function grokBackgroundTaskNotification( const task = update.task_snapshot const state = task.explicitly_killed ? 'idle' - : task.signal != null || (task.exit_code != null && task.exit_code !== 0) + : task.signal != null || task.error?.trim() || (task.exit_code != null && task.exit_code !== 0) ? 'blocked' - : task.exit_code === 0 - ? 'done' - : 'unverifiable' + : 'done' return { disposition: 'map', backgroundTasks: [snapshot(task, state)] } } diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts index 5b0f5cd18531..a0876365a548 100644 --- a/src/main/acp/acp-timeline-background-tasks.test.ts +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -122,11 +122,12 @@ describe('Grok background tasks through the shared timeline', () => { it.each([ [{ exit_code: 2, error: 'Command failed' }, 'blocked'], + [{ exit_code: null, error: 'Task failed' }, 'blocked'], [{ exit_code: null, signal: 'SIGTERM' }, 'blocked'], [{ exit_code: null, explicitly_killed: true }, 'idle'], - [{ exit_code: null }, 'unverifiable'] + [{ exit_code: null }, 'done'] ] as const)( - 'uses the provider outcome %j without claiming unseen success', + 'preserves explicit task completion and the provider outcome %j', async (snapshot, state) => { const fixture = await openAcpFixtureRig() await fixture.feed(await readAcpFixture('s6-background')) From 1f6c566a30d091dc87f89512665034aea95c75c6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:02:36 -0700 Subject: [PATCH 065/269] refactor(native-chat): derive record-store admission from the runtime's agent registrations Which agents a stored record may name and which agents the router drives came from two lists in the runtime, so a newly registered agent could be routed while its records were set aside. One list of registrations now holds each agent's definition and the factory for its adapter: the store's admitted agents are derived from it before the store opens, and the adapters are built from it once it has. --- .../structured-agent-runtime-registrations.ts | 118 ++++++++++++++++++ .../structured-agent-session-runtime.ts | 105 ++++------------ ...tructured-agent-stored-definitions.test.ts | 11 ++ 3 files changed, 154 insertions(+), 80 deletions(-) create mode 100644 src/main/runtime/structured-agent-runtime-registrations.ts diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts new file mode 100644 index 000000000000..bb183d65ba78 --- /dev/null +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -0,0 +1,118 @@ +// The structured agents this runtime registers, in one list. Each entry's definition decides which +// agents' records the store admits, and its factory builds the adapter the router drives that agent +// with once the store is open, so storage and routing cannot disagree about which agents exist. +// Adding an agent is one more entry. + +import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution' +import { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' +import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' +import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' +import { + agentSessionStoredAgents, + type AgentSessionStoredAgents +} from '../../shared/agent-session-stored-agent' +import type { + StructuredAgentSessionAdapter, + StructuredAgentSessionLifecycleEvent +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { readClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' +import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import type { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups' +import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' +import type { createStructuredAgentEnvironmentResolvers } from './structured-agent-shell-environment' +import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' + +/** What an agent's adapter is built from: the open store and the runtime around it. */ +export type StructuredAgentAdapterContext = { + deps: StructuredAgentSessionRuntimeDeps + store: AgentSessionRecordStore + environment: ReturnType + /** Hands the host an exit or other lifecycle event the agent observed. */ + deliverLifecycle: (event: StructuredAgentSessionLifecycleEvent) => void + followUps: ReturnType + /** Null until the host is built: the adapters are built first. */ + host: () => StructuredAgentSessionHost | null +} + +export type StructuredAgentRuntimeAdapter = StructuredAgentSessionAdapter & { + closeAll: () => Promise + /** Resolves once exits the agent observed have been published; absent when it publishes at once. */ + drainObservedExits?: () => Promise +} + +export type StructuredAgentRuntimeRegistration = { + definition: StructuredAgentDefinition + createAdapter: (context: StructuredAgentAdapterContext) => StructuredAgentRuntimeAdapter +} + +function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredAgentRuntimeAdapter { + const { deps, store, followUps, host } = context + return new CodexStructuredSessionAdapter({ + resolveLaunch: createCodexStructuredLaunchResolver({ + store, + resolveWorkspacePath: deps.resolveWorkspacePath, + resolveEnvironment: context.environment.resolveCodexEnvironment, + ...(deps.resolveCodexPermissionPolicy + ? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy } + : {}), + ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) + }), + ...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}), + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), + modelCatalog: agentModelCatalogStore, + onChildWorkEvidence: (sessionId, evidence) => + host()?.publishChildWorkEvidence(sessionId, evidence), + onDispatchSettledLate: followUps.onDispatchSettledLate, + onPrimaryThreadStoppedRunning: followUps.releaseUnansweredDispatches, + onEvent: (event) => { + if (event.type === 'ended' && 'cause' in event && event.cause === 'unexpected-exit') { + context.deliverLifecycle(event) + } + } + }) +} + +function createClaudeAdapter( + context: StructuredAgentAdapterContext +): StructuredAgentRuntimeAdapter { + const { deps, store, followUps, host } = context + return createStructuredClaudeRuntimeAdapter({ + store, + resolveWorkspacePath: deps.resolveWorkspacePath, + ...(deps.resolveClaudeCommand ? { resolveClaudeCommand: deps.resolveClaudeCommand } : {}), + ...(deps.resolveClaudeLaunchEnv ? { resolveClaudeLaunchEnv: deps.resolveClaudeLaunchEnv } : {}), + resolveClaudeInheritedEnv: context.environment.resolveClaudeInheritedEnv, + resolveClaudeAuthPolicy: deps.resolveClaudeAuthPolicy, + ...(deps.resolveClaudePermissionMode + ? { resolveClaudePermissionMode: deps.resolveClaudePermissionMode } + : {}), + ...(deps.getClaudeManagedAccountGateSettings + ? { + readClaudeManagedAccountGate: () => + readClaudeManagedAccountGateSettings(deps.getClaudeManagedAccountGateSettings!) + } + : {}), + onLifecycleEvent: context.deliverLifecycle, + onChildWorkEvidence: (sessionId, evidence) => + host()?.publishChildWorkEvidence(sessionId, evidence), + onDispatchSettledLate: followUps.onDispatchSettledLate, + onSessionIdle: followUps.releaseUnansweredDispatches, + ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), + modelCatalog: agentModelCatalogStore + }) +} + +export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRuntimeRegistration[] = + [ + { definition: CODEX_STRUCTURED_AGENT, createAdapter: createCodexAdapter }, + { definition: CLAUDE_STRUCTURED_AGENT, createAdapter: createClaudeAdapter } + ] + +/** What the record store admits: exactly the registered agents' declared storage. */ +export const STRUCTURED_AGENT_STORAGE: AgentSessionStoredAgents = agentSessionStoredAgents( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map((registration) => registration.definition) +) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 604b16a9107b..0d97bff5bb2f 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -20,26 +20,16 @@ import { type InstalledRuntime } from './structured-agent-session-runtime-teardown' import { AgentSessionRecoveryCapsule } from './agent-session-recovery-capsule' -import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution' import type { CodexStructuredPermissionPolicy } from '../codex/codex-structured-permission-policy' -import { - CodexStructuredSessionAdapter, - type CodexStructuredSessionAdapterDeps -} from '../codex/codex-structured-session-adapter' +import type { CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' -import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' -import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' -import { agentSessionStoredAgents } from '../../shared/agent-session-stored-agent' import { StructuredAgentSessionHost, type StructuredAgentSessionHostDeps } from '../native-chat/agent-session-wire/structured-agent-session-host' import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { - readClaudeManagedAccountGateSettings, - type ClaudeManagedAccountGateSettings -} from '../native-chat/claude-structured-managed-account-support' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { AgentSessionRecordStore } from './agent-session-record-store' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' @@ -53,7 +43,11 @@ import { import type { NativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' import { createStructuredAgentEnvironmentResolvers } from './structured-agent-shell-environment' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' -import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' +import { + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + STRUCTURED_AGENT_STORAGE, + type StructuredAgentAdapterContext +} from './structured-agent-runtime-registrations' import { createStructuredAgentSessionLifecycleDelivery } from './structured-agent-session-lifecycle-delivery' import { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' @@ -213,13 +207,6 @@ export async function stopStructuredAgentSessionRuntime(options?: { } } -/** The agents this runtime registers. Their definitions bound what the record store reads and - * writes, and each is routed to its adapter below. */ -const STRUCTURED_AGENT_STORAGE = agentSessionStoredAgents([ - CODEX_STRUCTURED_AGENT, - CLAUDE_STRUCTURED_AGENT -]) - async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { // Why thrown rather than defaulted: the caller is `@ts-nocheck`, so a dropped // field arrives here as `undefined`. Refusing to install is loud; guessing a @@ -252,7 +239,6 @@ async function installOnJournal( journalDatabase: JournalHostDatabase ): Promise { const envResolvers = createStructuredAgentEnvironmentResolvers(deps) - const { resolveCodexEnvironment, resolveClaudeInheritedEnv } = envResolvers const store = AgentSessionRecordStore.open({ journalDatabase, hostId: deps.hostId, @@ -262,69 +248,28 @@ async function installOnJournal( const lifecycle = createStructuredAgentSessionLifecycleDelivery({ handle: (event) => host?.handleAdapterEvent(event), logger: deps.logger, - // Claude publishes an observed exit only after its close ladder and transcript write; Codex - // publishes inside its own exit callback and needs nothing. - drainObservedExits: () => claude.drainObservedExits() - }) - const { onDispatchSettledLate, releaseUnansweredDispatches } = - createStructuredAgentSessionDispatchFollowUps({ host: () => host, logger: deps.logger }) - const codex = new CodexStructuredSessionAdapter({ - resolveLaunch: createCodexStructuredLaunchResolver({ - store, - resolveWorkspacePath: deps.resolveWorkspacePath, - resolveEnvironment: resolveCodexEnvironment, - ...(deps.resolveCodexPermissionPolicy - ? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy } - : {}), - ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) - }), - ...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}), - ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), - modelCatalog: agentModelCatalogStore, - onChildWorkEvidence: (sessionId, evidence) => - host?.publishChildWorkEvidence(sessionId, evidence), - onDispatchSettledLate, - onPrimaryThreadStoppedRunning: releaseUnansweredDispatches, - onEvent: (event) => { - if (event.type === 'ended' && 'cause' in event && event.cause === 'unexpected-exit') { - lifecycle.deliver(event) - } + // An agent that publishes an observed exit only after its own close work drains it here. + drainObservedExits: async () => { + await Promise.all(registrations.map(({ adapter }) => adapter.drainObservedExits?.())) } }) - const claude = createStructuredClaudeRuntimeAdapter({ + const context: StructuredAgentAdapterContext = { + deps, store, - resolveWorkspacePath: deps.resolveWorkspacePath, - ...(deps.resolveClaudeCommand ? { resolveClaudeCommand: deps.resolveClaudeCommand } : {}), - ...(deps.resolveClaudeLaunchEnv ? { resolveClaudeLaunchEnv: deps.resolveClaudeLaunchEnv } : {}), - resolveClaudeInheritedEnv, - resolveClaudeAuthPolicy: deps.resolveClaudeAuthPolicy, - ...(deps.resolveClaudePermissionMode - ? { resolveClaudePermissionMode: deps.resolveClaudePermissionMode } - : {}), - ...(deps.getClaudeManagedAccountGateSettings - ? { - readClaudeManagedAccountGate: () => - readClaudeManagedAccountGateSettings(deps.getClaudeManagedAccountGateSettings!) - } - : {}), - onLifecycleEvent: (event) => lifecycle.deliver(event), - onChildWorkEvidence: (sessionId, evidence) => - host?.publishChildWorkEvidence(sessionId, evidence), - onDispatchSettledLate, - onSessionIdle: releaseUnansweredDispatches, - ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), - ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), - modelCatalog: agentModelCatalogStore - }) - const adapter = new StructuredAgentSessionAdapterRouter( - [ - { definition: CODEX_STRUCTURED_AGENT, adapter: codex }, - { definition: CLAUDE_STRUCTURED_AGENT, adapter: claude } - ], - async () => { - await Promise.all([codex.closeAll(), claude.closeAll()]) - } + environment: envResolvers, + deliverLifecycle: lifecycle.deliver, + followUps: createStructuredAgentSessionDispatchFollowUps({ + host: () => host, + logger: deps.logger + }), + host: () => host + } + const registrations = STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map( + ({ definition, createAdapter }) => ({ definition, adapter: createAdapter(context) }) ) + const adapter = new StructuredAgentSessionAdapterRouter(registrations, async () => { + await Promise.all(registrations.map((registration) => registration.adapter.closeAll())) + }) host = new StructuredAgentSessionHost({ store, adapter, diff --git a/src/main/runtime/structured-agent-stored-definitions.test.ts b/src/main/runtime/structured-agent-stored-definitions.test.ts index a5842fe9c796..c3e7fe0927d1 100644 --- a/src/main/runtime/structured-agent-stored-definitions.test.ts +++ b/src/main/runtime/structured-agent-stored-definitions.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it } from 'vitest' import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' +import { + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + STRUCTURED_AGENT_STORAGE +} from './structured-agent-runtime-registrations' describe('what the shipped definitions let a record store', () => { it('matches the storage every older build wrote', () => { @@ -14,4 +18,11 @@ describe('what the shipped definitions let a record store', () => { expect(CLAUDE_STRUCTURED_AGENT.accountHomeVariable).toBe('CLAUDE_CONFIG_DIR') expect(CODEX_STRUCTURED_AGENT.accountHomeVariable).toBe('CODEX_HOME') }) + + it('admits exactly the agents the runtime routes, stored as before', () => { + expect([...STRUCTURED_AGENT_STORAGE.keys()]).toEqual( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) + ) + expect(STRUCTURED_AGENT_STORAGE).toEqual(CLAUDE_AND_CODEX_STORED_AGENTS) + }) }) From 0def03e17c2351ca667ca815689eef3d5305c6e6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:17:19 -0700 Subject: [PATCH 066/269] fix(native-chat): hand the exit drain a promise for every registered agent --- src/main/runtime/structured-agent-session-runtime.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 0d97bff5bb2f..055530627466 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -250,7 +250,9 @@ async function installOnJournal( logger: deps.logger, // An agent that publishes an observed exit only after its own close work drains it here. drainObservedExits: async () => { - await Promise.all(registrations.map(({ adapter }) => adapter.drainObservedExits?.())) + await Promise.all( + registrations.map(({ adapter }) => adapter.drainObservedExits?.() ?? Promise.resolve()) + ) } }) const context: StructuredAgentAdapterContext = { From dcddf27da46b08ae85158151ae2182444b6d0f83 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:25:44 -0700 Subject: [PATCH 067/269] fix(native-chat): let the adoption conflict check read any agent's ownership Ownership rows name any registered agent since the stored records opened to them; the adoption check compares by agent, so it takes the same open id. Only Claude and Codex still adopt. --- .../native-chat/structured-agent-session-history-adoption.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/structured-agent-session-history-adoption.ts b/src/main/native-chat/structured-agent-session-history-adoption.ts index 662b93dcb2c0..b5e0ac97fe4c 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -11,12 +11,14 @@ import { agentSessionWireProviderHandle, type AgentSessionWireProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease-adjudication' export type StructuredAgentSessionAdoptionOwnership = { sessionId: string - provider: 'claude' | 'codex' + /** Any registered agent's chat may hold the conversation, though only Claude and Codex adopt. */ + provider: StructuredAgentId providerSessionId: string lease: AgentSessionLease } From d0e8a074b0dcd0d1761e5fbf3782136bbff46320 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:34:46 -0700 Subject: [PATCH 068/269] Match the background reset test to the final timeline grammar --- src/main/acp/acp-timeline-background-tasks.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts index a0876365a548..b254c24b101a 100644 --- a/src/main/acp/acp-timeline-background-tasks.test.ts +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -73,7 +73,7 @@ describe('Grok background tasks through the shared timeline', () => { fixture.apply([ boundary === 'end' ? { type: 'session.ended', verdict: { state: 'unverifiable' } } - : { type: 'session.reset', namespace: 'replacement-session', generation: 'gen-3' } + : { type: 'session.reset', namespace: 'replacement-session' } ]) const [after] = await taskRows(fixture) expect(after?.row.itemId).toBe(before.row.itemId) From 71430ae068cca43eebe76c7395b109706a73d967 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:41:28 -0700 Subject: [PATCH 069/269] Give task completion fixtures a specific evidence type --- src/main/acp/acp-timeline-background-tasks.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts index b254c24b101a..a25a37020093 100644 --- a/src/main/acp/acp-timeline-background-tasks.test.ts +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -33,7 +33,15 @@ function started(id = taskId) { } } -function completed(snapshot: object = { exit_code: 0 }) { +type TaskCompletionEvidence = { + exit_code?: number | null + error?: string + output?: string + signal?: string | number | null + explicitly_killed?: boolean +} + +function completed(snapshot: TaskCompletionEvidence = { exit_code: 0 }) { return { sessionId: 'session-1', update: { sessionUpdate: 'task_completed', task_snapshot: { task_id: taskId, ...snapshot } } From b3188333b6309c93d808caae5d944fa915283312 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:06:05 -0700 Subject: [PATCH 070/269] Let a later death proof correct the calls an unverifiable settle closed A settle with no proof of the child's death closed its running calls as plain failed, so a proof written afterwards corrected the turn to interrupted but could not find the calls. The call now keeps endedAs: 'unverifiable' beside its failed state, and the stale settle revises exactly those calls whose owner the proof names. Older builds still read them as failed. --- ...session-dead-generation-settlement.test.ts | 3 +- ...gent-session-dead-generation-settlement.ts | 9 +- ...agent-session-late-proof-tool-ends.test.ts | 170 ++++++++++++++++++ ...ctured-agent-session-stale-turn-verdict.ts | 33 ++++ .../agent-journal-tool-call-lifecycle.ts | 15 +- src/shared/agent-session-journal-types.ts | 6 +- .../native-chat-tool-run-outcome.test.ts | 9 + ...on-interrupted-tool-downgrade.unit.test.ts | 40 +++-- 8 files changed, 265 insertions(+), 20 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index be44cdf93b70..3bb37371ec48 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -122,8 +122,9 @@ describe('dead structured-session generation settlement', () => { ]) ) expect(snapshot.items.some((item) => item.body.kind === 'status')).toBe(false) + // Kept beside its failed state, so a later proof naming its owner can correct it. const tool = snapshot.items.find((item) => item.body.kind === 'tool-call') - expect(tool?.body).not.toHaveProperty('endedAs') + expect(tool?.body).toMatchObject({ state: 'failed', endedAs: 'unverifiable' }) }) it('adds one actionable outcome for observed active-work failure and is idempotent', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index dd69ea9e23b4..474436632fd3 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 @@ -29,6 +29,7 @@ import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-re import { endedByPersonsStop, provenUnverifiableTurnRevisions, + provenUnverifiedToolCallRevisions, runningTurnLifecycleRevisions, stopFoundTurnLiveAt, turnVerdictFromDeathEvidence, @@ -208,8 +209,9 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { * Settles whatever a generation with no child in this process left running: found when a new child * is acquired, or when a chat is reopened for reading. Derived from the journal and the lease's * death evidence each time, so nothing is owed in between. Proven death ends the turn interrupted, - * and a proof written after an earlier settle revises what that settle left `unverifiable`. Must - * run before a new child's buffered events land, or a live turn would be judged. + * and a proof written after an earlier settle revises what that settle left `unverifiable`, the + * turn and the calls it closed alike. Must run before a new child's buffered events land, or a + * live turn would be judged. */ export async function settleStaleStructuredAgentSessionState(input: { journal: AgentSessionJournal @@ -232,7 +234,8 @@ export async function settleStaleStructuredAgentSessionState(input: { // Per attempt: a retry re-partitions only what is left, and a reused chunk id would skip it. const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}` const settlementId = `stale-session:${input.sessionId}:${input.fence}:${generation}` - const mutations: JournalLifecycleMutationInput[] = [] + // Calls an earlier settle closed with no proof, revised once a proof names their owner. + const mutations = provenUnverifiedToolCallRevisions(items, input.deathEvidence, journal) for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) const body = terminalDeadGenerationBody(item, verdictFor(item)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts new file mode 100644 index 000000000000..3e419d28aab8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts @@ -0,0 +1,170 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentJournalToolCallLifecycle } from '../../../shared/agent-journal-tool-call-lifecycle' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + settleStaleStructuredAgentSessionState, + settleStructuredAgentSessionDeadGeneration +} from './structured-agent-session-dead-generation-settlement' + +const SESSION = 'session-late-proof' +const THREAD = 'thread-1' + +/** A probe that found the given owner's child gone at 9000. */ +function proofFor(ownerFence: number | undefined): AgentSessionDeathEvidence { + return { + kind: 'pid-absent', + detail: 'recorded pid absent on host', + observedAt: 9_000, + ...(ownerFence === undefined ? {} : { ownerFence }), + lastProvenAliveAt: 150 + } +} + +function identityOf(turnId: string, ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: THREAD, turnId, ordinal } +} + +let root: string +let journals: ReturnType +let journal: AgentSessionJournal + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-late-proof-')) + journals = createTrackedJournalOpener() + journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + stateDirectory: root, + now: () => 100 + }) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +/** A running turn its owner wrote, with a running call and the given finished calls in it. */ +async function seedTurn( + turnId: string, + fence: number, + finished: AgentJournalItemBody[] = [] +): Promise { + const turn = identityOf(turnId, 0) + await journal.appendItem( + turn, + { kind: 'turn', turnId, state: 'running', startedAt: 100 }, + { fence, turnScope: { kind: 'thread' } } + ) + const calls: AgentJournalItemBody[] = [ + { kind: 'tool-call', name: 'shell', input: { command: 'sleep 20' }, state: 'running' }, + ...finished + ] + for (const [index, body] of calls.entries()) { + await journal.appendItem(identityOf(turnId, index + 1), body, { + fence, + turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(turn) } + }) + } +} + +function settle(fence: number, deathEvidence: AgentSessionDeathEvidence | null) { + return settleStaleStructuredAgentSessionState({ + journal, + sessionId: SESSION, + fence, + acquisitionGeneration: `generation-${fence}`, + deathEvidence + }) +} + +function turnState(turnId: string) { + const item = journal + .snapshot() + .items.find((row) => readAgentJournalTurn(row.body)?.turnId === turnId) + return readAgentJournalTurn(item?.body)?.state +} + +function callLifecycle(turnId: string, ordinal: number) { + const key = agentJournalItemKey(identityOf(turnId, ordinal)) + const body = journal.snapshot().items.find((item) => item.itemId === key)?.body + return body?.kind === 'tool-call' ? agentJournalToolCallLifecycle(body) : undefined +} + +describe('a proof written after an unverifiable settle', () => { + it('corrects the call that settle closed along with its turn, and only once', async () => { + await seedTurn('turn-1', 1, [ + // Failed on its own before the death: no proof of the death makes it interrupted. + { kind: 'tool-call', name: 'shell', input: { command: 'false' }, state: 'failed' }, + { kind: 'tool-call', name: 'read', input: { path: 'a' }, state: 'completed' } + ]) + + await settle(2, null) + expect(turnState('turn-1')).toBe('unverifiable') + // Nothing proved the end, so the call reads failed, as it always has. + expect(callLifecycle('turn-1', 1)).toBe('failed') + + await settle(3, proofFor(1)) + expect(turnState('turn-1')).toBe('interrupted') + expect(callLifecycle('turn-1', 1)).toBe('interrupted') + expect(callLifecycle('turn-1', 2)).toBe('failed') + expect(callLifecycle('turn-1', 3)).toBe('completed') + + const corrected = journal.cursor() + await expect(settle(4, proofFor(1))).resolves.toBe(0) + expect(journal.cursor()).toEqual(corrected) + }) + + it("leaves a call alone when the proof names another owner's child", async () => { + await seedTurn('turn-1', 1) + await settle(2, null) + await seedTurn('turn-2', 2) + await settle(3, null) + expect(callLifecycle('turn-2', 1)).toBe('failed') + + const unrevised = journal.cursor() + // A later owner's death, and an older build's proof naming no owner, prove nothing about it. + await expect(settle(4, proofFor(5))).resolves.toBe(0) + await expect(settle(4, { ...proofFor(undefined), kind: 'exit-observed' })).resolves.toBe(0) + expect(journal.cursor()).toEqual(unrevised) + + await settle(4, proofFor(1)) + expect(callLifecycle('turn-1', 1)).toBe('interrupted') + expect(turnState('turn-2')).toBe('unverifiable') + expect(callLifecycle('turn-2', 1)).toBe('failed') + }) + + it('corrects a call an unverifiable restart eviction closed', async () => { + await seedTurn('turn-1', 7) + await settleStructuredAgentSessionDeadGeneration({ + journal, + sessionId: SESSION, + fence: 8, + settlementId: `restart-eviction:${SESSION}:8`, + pendingSubmissionReason: 'provider_exited_before_acknowledgement', + verdict: { state: 'unverifiable' }, + showUnexpectedExitOutcome: false + }) + expect(callLifecycle('turn-1', 1)).toBe('failed') + + await settle(9, proofFor(7)) + expect(turnState('turn-1')).toBe('interrupted') + expect(callLifecycle('turn-1', 1)).toBe('interrupted') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index d7cd404e468c..ccd58b4af4ed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -6,6 +6,10 @@ // ladder — carries none, and neither does a later owner's death; the turn is then `unverifiable` // with no end at all, until a proof naming its owner is written and revises it. +import { + interruptedAgentJournalToolCall, + isUnverifiedEndAgentJournalToolCall +} from '../../../shared/agent-journal-tool-call-lifecycle' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -120,6 +124,35 @@ export function provenUnverifiableTurnRevisions( }) } +/** The calls those settles closed with no proof, revised by the same proof: each only when it names + * the owner that wrote the call, so a call that failed on its own stays failed. */ +export function provenUnverifiedToolCallRevisions( + items: readonly AgentJournalRenderItem[], + evidence: AgentSessionDeathEvidence | null | undefined, + journal: Pick +): JournalLifecycleMutationInput[] { + const ownerFence = evidence?.ownerFence + if (ownerFence === undefined) { + return [] + } + return items.flatMap((item): JournalLifecycleMutationInput[] => { + const identity = parseAgentJournalItemKey(item.itemId) + return identity && + item.body.kind === 'tool-call' && + isUnverifiedEndAgentJournalToolCall(item.body) && + journal.itemFence(item.itemId) === ownerFence + ? [ + { + kind: 'item', + identity, + body: interruptedAgentJournalToolCall(item.body), + turnScope: item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + } + ] + : [] + }) +} + function turnLifecycleRevision( item: AgentJournalRenderItem, turn: AgentJournalTurnLifecycle, diff --git a/src/shared/agent-journal-tool-call-lifecycle.ts b/src/shared/agent-journal-tool-call-lifecycle.ts index 815f8acc0654..6793c9166e7d 100644 --- a/src/shared/agent-journal-tool-call-lifecycle.ts +++ b/src/shared/agent-journal-tool-call-lifecycle.ts @@ -32,12 +32,21 @@ export function interruptedAgentJournalToolCall( /** What a call still running when its turn or session ended becomes. Only a proven interruption * cuts it short: a turn the provider completed around it, or an end the host could not verify, - * proves none, so it reads failed as it always has. */ + * proves none, so it reads failed as it always has. An unverified end is kept beside that, so a + * proof written later can still find the call. */ export function endedRunningAgentJournalToolCall( call: AgentJournalToolCallItem, end: 'interrupted' | 'completed' | 'unverifiable' ): AgentJournalToolCallItem { - return end === 'interrupted' - ? interruptedAgentJournalToolCall(call) + if (end === 'interrupted') { + return interruptedAgentJournalToolCall(call) + } + return end === 'unverifiable' + ? { ...call, state: 'failed', endedAs: 'unverifiable' } : { ...call, state: 'failed' } } + +/** A call an unverified end closed: still waiting on a proof that its owner died. */ +export function isUnverifiedEndAgentJournalToolCall(call: ToolCallLifecycleFields): boolean { + return call.state === 'failed' && call.endedAs === 'unverifiable' +} diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index d0d72f77b342..e641f35f129b 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -112,8 +112,10 @@ export type AgentJournalMessageItem = { export type AgentJournalToolCallState = 'running' | 'completed' | 'failed' /** How a call that did not finish on its own ended, finer than its `failed` state. Persisted and - * open for growth: a reader that cannot place a value reads `state`. */ -export const AGENT_JOURNAL_TOOL_CALL_ENDINGS = ['interrupted'] as const + * open for growth: a reader that cannot place a value reads `state`. `unverifiable` is a call its + * session's end closed when nothing proved that end, so a later proof naming its owner finds it + * and revises it to `interrupted`, as it does the turn. */ +export const AGENT_JOURNAL_TOOL_CALL_ENDINGS = ['interrupted', 'unverifiable'] as const export type AgentJournalToolCallEnding = (typeof AGENT_JOURNAL_TOOL_CALL_ENDINGS)[number] export type AgentJournalToolCallItem = NativeChatToolMetadata & { diff --git a/src/shared/native-chat-tool-run-outcome.test.ts b/src/shared/native-chat-tool-run-outcome.test.ts index 10337ab2d723..2638ee09bc9e 100644 --- a/src/shared/native-chat-tool-run-outcome.test.ts +++ b/src/shared/native-chat-tool-run-outcome.test.ts @@ -132,6 +132,15 @@ describe('nativeChatToolRunOutcome', () => { ).toEqual({ failedCallCount: 1, interruptedCallCount: 1, succeeded: false }) }) + it('counts a call an unverified end closed as failed until a proof corrects it', () => { + const block: NativeChatBlock = { ...call('a', 'failed'), endedAs: 'unverifiable' } + expect(nativeChatToolRunOutcome([block], {})).toEqual({ + failedCallCount: 1, + interruptedCallCount: 0, + succeeded: false + }) + }) + it('reads an ending it cannot place as the failed state beside it', () => { // A value a newer build may persist; the schema keeps the field open. const block: NativeChatBlock = JSON.parse( diff --git a/tests/e2e/cross-version-wire/agent-session-interrupted-tool-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-interrupted-tool-downgrade.unit.test.ts index ad00d1ec1a00..2138e3abb7a8 100644 --- a/tests/e2e/cross-version-wire/agent-session-interrupted-tool-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-interrupted-tool-downgrade.unit.test.ts @@ -2,7 +2,10 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, test } from 'vitest' -import { interruptedAgentJournalToolCall } from '../../../src/shared/agent-journal-tool-call-lifecycle' +import { + endedRunningAgentJournalToolCall, + interruptedAgentJournalToolCall +} from '../../../src/shared/agent-journal-tool-call-lifecycle' import type { AgentJournalItemIdentity } from '../../../src/shared/agent-session-journal-types' import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' @@ -59,13 +62,22 @@ test('an older host and client read a call a stop cut short as the failure they output: { head: 'partial', byteLength: 7, digest: 'd', truncated: false } }) await journal.appendItem(call(0), cutShort, { fence: 1, turnScope: { kind: 'thread' } }) + // And one an end nothing proved closed, kept so a later proof can still correct it. + const unverified = endedRunningAgentJournalToolCall( + { kind: 'tool-call', name: 'shell', input: { command: 'sleep 30' }, state: 'running' }, + 'unverifiable' + ) + await journal.appendItem(call(1), unverified, { fence: 1, turnScope: { kind: 'thread' } }) const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) if (!since.ok) { throw new Error(`expected rows, got reset ${since.reset}`) } const rows: JournalRow[] = since.rows const current = journal.snapshot().items - expect(current[0]?.body).toMatchObject({ state: 'failed', endedAs: 'interrupted' }) + expect(current.map((item) => item.body)).toMatchObject([ + { state: 'failed', endedAs: 'interrupted' }, + { state: 'failed', endedAs: 'unverifiable' } + ]) const checkout = await materializeReleaseCheckout(BASELINE_REF) const [reducer, schemas, projection, outcome] = await Promise.all( @@ -99,22 +111,28 @@ test('an older host and client read a call a stop cut short as the failure they const runOutcome = releaseExport(outcome, 'nativeChatToolRunOutcome') const settled = { activeTurnIsWorking: false } - // The older host, after a downgrade, folds the row it never wrote as a failed call. + const blocksOf = (items: OldRenderItem[]) => + items.flatMap((item) => project(item)?.blocks ?? []) + + // The older host, after a downgrade, folds the rows it never wrote as failed calls. const state = createState(SESSION, journal.epoch) for (const row of rows) { applyRow(state, row) } - const [folded] = render(state).items - expect(folded?.body).toMatchObject({ kind: 'tool-call', state: 'failed' }) - expect(runOutcome(project(folded)?.blocks ?? [], settled)).toEqual({ - failedCallCount: 1, + const folded = render(state).items + expect(folded.map((item) => item.body)).toMatchObject([ + { kind: 'tool-call', state: 'failed' }, + { kind: 'tool-call', state: 'failed' } + ]) + expect(runOutcome(blocksOf(folded), settled)).toEqual({ + failedCallCount: 2, succeeded: false }) - // The older client reads this host's render item the same way. - const [read] = current.map((item) => renderItemSchema.parse(item)) - expect(runOutcome(project(read)?.blocks ?? [], settled)).toEqual({ - failedCallCount: 1, + // The older client reads this host's render items the same way. + const read = current.map((item) => renderItemSchema.parse(item)) + expect(runOutcome(blocksOf(read), settled)).toEqual({ + failedCallCount: 2, succeeded: false }) From 99b9190ca64806689c49f50f3b6dbad88d3ad403 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:06:05 -0700 Subject: [PATCH 071/269] Render a cut-short call's output on mobile, with and without proof --- .../session/MobileNativeChatMessage.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 67e901328667..dbaedb660567 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -1,7 +1,9 @@ import { createElement } from 'react' import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalToolCallItem } from '../../../src/shared/agent-session-journal-types' import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary' +import { projectStructuredItemToNativeChat } from '../../../src/shared/structured-agent-session-projection' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' vi.mock('react-native', async () => { @@ -193,6 +195,51 @@ describe('MobileNativeChatMessage', () => { expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1) }) + describe('output a call left when it ended early', () => { + function projectedCall(ending: Pick): NativeChatMessage { + const body: AgentJournalToolCallItem = { + kind: 'tool-call', + name: 'shell', + input: { command: 'sleep 20' }, + state: 'failed', + ...ending, + output: { head: 'partial', byteLength: 7, digest: 'd', truncated: false } + } + const projected = projectStructuredItemToNativeChat({ + itemId: 'call', + sequence: 1, + revision: 1, + observedAt: 100, + body + }) + if (!projected) { + throw new Error('the call projects no message') + } + return { ...projected, id: 'a1', source: 'transcript' } + } + const outputStyle = (message: NativeChatMessage): unknown => { + const tree = render(message, { toolsExpanded: true }) + const output = tree.root + .findAllByType('Text' as never) + .find((node) => node.children.join('') === 'partial') + return output?.parent?.props.style + } + + it('shows the output a stop cut short without the error tint', () => { + expect(outputStyle(projectedCall({ endedAs: 'interrupted' }))).toEqual([ + expect.any(Object), + false + ]) + }) + + it('keeps the error tint on a call nothing proved was cut short', () => { + expect(outputStyle(projectedCall({ endedAs: 'unverifiable' }))).toEqual([ + expect.any(Object), + expect.objectContaining({ backgroundColor: expect.any(String) }) + ]) + }) + }) + describe('structured activity UI', () => { const runningCall = { type: 'tool-call' as const, From 892d8231df9334cb49b19f65031b724d71ef0146 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:23:52 -0700 Subject: [PATCH 072/269] Share managed provider launch, exit observation, and close --- .../claude/claude-agent-sdk-process-spawn.ts | 44 +-- .../claude/claude-child-exit-proof-ladder.ts | 53 ++-- .../claude/claude-stream-json-connection.ts | 63 ++--- src/main/codex/codex-app-server-connection.ts | 95 ++----- .../managed-provider-process.test.ts | 259 ++++++++++++++++++ .../managed-provider-process.ts | 139 ++++++++++ .../provider-process-close.ts | 74 +++++ .../retryable-process-exit-proof.ts | 16 +- 8 files changed, 583 insertions(+), 160 deletions(-) create mode 100644 src/main/provider-process/managed-provider-process.test.ts create mode 100644 src/main/provider-process/managed-provider-process.ts create mode 100644 src/main/provider-process/provider-process-close.ts diff --git a/src/main/claude/claude-agent-sdk-process-spawn.ts b/src/main/claude/claude-agent-sdk-process-spawn.ts index 96fefa0e275b..88886415cdb7 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.ts @@ -1,6 +1,10 @@ import type { SpawnOptions as ClaudeAgentSdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk' import { spawnProcess } from '../../shared/child-process/run-process' -import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor' +import { + spawnManagedProviderProcess, + type ManagedProviderProcess +} from '../provider-process/managed-provider-process' +import { claudeChildClosePolicy } from './claude-child-exit-proof-ladder' /** Derived rather than imported: only src/shared/child-process may name node:child_process. */ type ClaudeCodeChild = ReturnType @@ -12,6 +16,7 @@ export type ClaudeCodeProcessSpawn = { spawn: (options: ClaudeAgentSdkSpawnOptions) => ClaudeCodeChild /** The retained child, so Orca keeps its own tree-kill and exit-proof ladder. Null until the SDK spawns. */ readonly child: ClaudeCodeChild | null + readonly managed: ManagedProviderProcess | null /** * Ownership proof: the durable lease adjudicates on this pid plus start time plus the spawn * token. On POSIX it is the provider supervisor's, which outlives Claude by construction. @@ -46,32 +51,26 @@ export function createClaudeCodeProcessSpawn( spawnImpl: typeof spawnProcess = spawnProcess, platform: NodeJS.Platform = process.platform ): ClaudeCodeProcessSpawn { - let child: ClaudeCodeChild | null = null + let managed: ManagedProviderProcess | null = null let stderrTail = '' - let supervised = false return { spawn: (options) => { - const spec = createProviderSpawnSpec( + // The SDK's abort signal cannot bypass Orca's child-owned close. + managed = spawnManagedProviderProcess( { command: options.command, args: [...options.args], ...(options.cwd === undefined ? {} : { cwd: options.cwd }) }, - definedEnv(options.env), - platform + { + spawnImpl, + platform, + inheritedEnv: definedEnv(options.env), + site: 'claude-stream-json-teardown', + policy: claudeChildClosePolicy + } ) - // Why `options.signal` is dropped: it would let the SDK kill the child outside - // Orca's ladder, and close() may never report an exit it did not observe. - const spawned = spawnImpl({ - program: spec.program, - args: spec.args, - cwd: spec.cwd, - env: spec.env, - detached: spec.detached, - stdio: ['pipe', 'pipe', 'pipe'] - }) - child = spawned - supervised = spec.supervised + const spawned = managed.child // The SDK drains stderr only for its own local spawn, so a custom spawner must: // otherwise the child blocks on a full pipe and exit errors lose their tail. spawned.stderr.setEncoding('utf8').on('data', (chunk: string) => { @@ -80,13 +79,16 @@ export function createClaudeCodeProcessSpawn( return spawned }, get child() { - return child + return managed?.child ?? null + }, + get managed() { + return managed }, get pid() { - return child?.pid + return managed?.child.pid }, get supervised() { - return supervised + return managed?.supervised ?? false }, get stderrTail() { return stderrTail diff --git a/src/main/claude/claude-child-exit-proof-ladder.ts b/src/main/claude/claude-child-exit-proof-ladder.ts index 41015b0192f0..67efa47cef45 100644 --- a/src/main/claude/claude-child-exit-proof-ladder.ts +++ b/src/main/claude/claude-child-exit-proof-ladder.ts @@ -1,5 +1,9 @@ import type { SpawnedProcess } from '../../shared/child-process/run-process' -import { waitForProcessExitUntil } from '../provider-process/provider-process-exit-deadline' +import { + closeProviderProcess, + type ProviderProcessClosePolicy +} from '../provider-process/provider-process-close' +import type { ManagedProviderProcess } from '../provider-process/managed-provider-process' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' import type { ClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' @@ -8,11 +12,21 @@ export const GRACEFUL_EXIT_MS = 1_500 export const SUPERVISED_GRACEFUL_EXIT_MS = PROVIDER_SUPERVISOR_MAX_STOP_MS + 500 const FORCED_EXIT_MS = 1_000 +export function claudeChildClosePolicy(supervised: boolean): ProviderProcessClosePolicy { + return { + gracefulExitMs: supervised ? SUPERVISED_GRACEFUL_EXIT_MS : GRACEFUL_EXIT_MS, + forcedExitMs: FORCED_EXIT_MS, + signalSupervisorOnClose: true, + requireTreeExit: true + } +} + export type ClaudeChildExitProofInput = { child: Pick exitPromise: Promise exited: () => boolean tree?: ClaudeChildTreeReaper + managed?: ManagedProviderProcess /** The child is the POSIX provider supervisor: SIGTERM stops Claude, which reaps its tools. */ supervised?: boolean } @@ -22,33 +36,14 @@ export async function proveClaudeChildExitWithReaper( createTree: () => ClaudeChildTreeReaper ): Promise { const tree = input.tree ?? createTree() - // Arm before the stop: only a live root can identify its descendants. - await tree.capture() - try { - input.child.stdin?.end() - } catch { - // The reap below still owns the process. - } - // Stdin end alone lets Claude finish its turn, tools and edits included; a close is a stop. - // Windows has no supervisor, and a direct SIGTERM there is TerminateProcess. - if (input.supervised && !input.exited()) { - input.child.kill('SIGTERM') - } - let reaped = false - if (!input.exited()) { - await waitForProcessExitUntil( - input.exitPromise, - input.supervised ? SUPERVISED_GRACEFUL_EXIT_MS : GRACEFUL_EXIT_MS - ) - if (!input.exited()) { - reaped = true - await tree.refresh?.() - await tree.reap() - await waitForProcessExitUntil(input.exitPromise, FORCED_EXIT_MS) - } - } - if (!reaped && input.exited() && tree.treeVerdict !== 'exited') { - await tree.reap() + if (input.managed) { + return (await input.managed.close(tree)) === 'exited' } - return input.exited() && tree.treeVerdict === 'exited' + const result = await closeProviderProcess({ + ...input, + tree, + policy: claudeChildClosePolicy(input.supervised ?? false), + terminateTree: async () => false + }) + return result.verdict === 'exited' } diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index d6511a998630..887076b3a537 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -148,8 +148,9 @@ export async function openClaudeStreamJsonConnection( ...(handlers.onUserDialog ? { onUserDialog: handlers.onUserDialog } : {}) } }) - const child = spawner.child - if (!child) { + const managed = spawner.managed + const child = managed?.child + if (!child || !managed) { throw new Error('the claude agent SDK returned without spawning a child') } // This child owns the account's credentials for as long as it runs, exactly as a @@ -158,11 +159,8 @@ export async function openClaudeStreamJsonConnection( // path exists. const authGateKey = randomUUID() const releaseAuthGate = (): void => markClaudeStructuredChildExited(authGateKey) - let exited = false let exitStatus: ExitStatus | null = null let closing = false - let processless = false - let prePidSpawnError = false let terminalError: Error | null = null let faultReported = false let exitReported = false @@ -170,7 +168,7 @@ export async function openClaudeStreamJsonConnection( let readingBarrier: Promise | null = null let releaseReadingBarrier: (() => void) | null = null const pauseReading = (): void => { - if (closing || exited || terminalError || readingBarrier) { + if (closing || managed.exited || terminalError || readingBarrier) { return } readingBarrier = new Promise((resolve) => { @@ -185,7 +183,7 @@ export async function openClaudeStreamJsonConnection( } const waitUntilReadable = (): Promise => readingBarrier ?? Promise.resolve() // One reaper per child: every close attempt and error-path reap shares its proof. - const rootSettled = (): boolean => exited || processless + const rootSettled = (): boolean => managed.rootVerdict === 'exited' const tree = createClaudeChildTreeReaper(child, { exited: rootSettled }) // Arm lazily on actual child output instead of issuing a process-table scan for @@ -207,20 +205,7 @@ export async function openClaudeStreamJsonConnection( armTreeOnOutput() } - let settleExit = (): void => {} - const exitPromise = new Promise((resolve) => { - settleExit = resolve - }) - const markExited = (): void => { - exited = true - releaseAuthGate() - settleExit() - } - child.on('exit', (code, signal) => { - exitStatus = { code, signal } - markExited() - handleUnexpectedEnd() - }) + const exitPromise = managed.exitPromise const handleUnexpectedEnd = (cause?: Error): void => { resumeReading() @@ -230,7 +215,7 @@ export async function openClaudeStreamJsonConnection( faultReported = true handlers.onFault?.(terminalError) } - if (exited && !exitReported) { + if (managed.exited && !exitReported) { exitReported = true handlers.onExit?.(terminalError, { expected: closing }) } @@ -262,29 +247,26 @@ export async function openClaudeStreamJsonConnection( } catch (error: unknown) { // The SDK ends its generator in error when the child dies or the transport // fails; a transport failure with a live child still has to reap the tree. - if (!closing && !exited) { + if (!closing && !managed.exited) { void tree.reap() } handleUnexpectedEnd(error instanceof Error ? error : new Error(String(error))) } })() + managed.onExit((exit) => { + exitStatus = exit + releaseAuthGate() + handleUnexpectedEnd() + }) child.on('error', (error) => { - if (spawner.pid === undefined) { - prePidSpawnError = true - } - if (!closing && !exited) { + if (!closing && !managed.exited) { void tree.reap() } handleUnexpectedEnd(error) }) child.on('close', () => { - // Covers the spawn-failure path too, where no 'exit' ever arrives. releaseAuthGate() - if (prePidSpawnError && spawner.pid === undefined) { - processless = true - settleExit() - } handleUnexpectedEnd() }) child.stdin.on('error', (error) => { @@ -302,7 +284,13 @@ export async function openClaudeStreamJsonConnection( markClaudeStructuredChildSpawned(authGateKey) const send: ClaudeStreamJsonConnection['send'] = (message, beforeDispatch) => { - if (closing || exited || terminalError || child.stdin.destroyed || !child.stdin.writable) { + if ( + closing || + managed.exited || + terminalError || + child.stdin.destroyed || + !child.stdin.writable + ) { return Promise.reject( claudeUnwrittenUserMessageError( terminalError ?? new Error('claude stream-json connection is closed') @@ -326,11 +314,12 @@ export async function openClaudeStreamJsonConnection( exitPromise, exited: rootSettled, tree, - supervised: spawner.supervised + supervised: spawner.supervised, + managed }) inbox.fail(new Error('claude stream-json connection closed')) if (!proven) { - if (exited && tree.treeVerdict === 'live') { + if (managed.exited && tree.treeVerdict === 'live') { console.warn('[claude-stream-json] root exited but a descendant survived the close:', { pid: spawner.pid }) @@ -359,11 +348,11 @@ export async function openClaudeStreamJsonConnection( return spawner.pid }, get closed() { - return closing || exited || terminalError !== null + return closing || managed.exited || terminalError !== null }, get exitVerdict() { return { - root: processless ? 'processless' : exited ? 'exited' : 'live', + root: managed.processless ? 'processless' : managed.exited ? 'exited' : 'live', tree: tree.treeVerdict } as const }, diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 31103579c23b..961390aa5d58 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -1,15 +1,10 @@ import { spawnProcess } from '../../shared/child-process/run-process' -import { RetryableProcessExitProof } from '../../shared/child-process/retryable-process-exit-proof' +import { spawnManagedProviderProcess } from '../provider-process/managed-provider-process' import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' -import { - createProviderSpawnSpec, - PROVIDER_SUPERVISOR_MAX_STOP_MS -} from '../provider-process/provider-process-supervisor' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' import { buildCodexAppServerExitError } from './codex-app-server-exit-error' import { initializeCodexAppServerConnection } from './codex-app-server-handshake' import { CodexAppServerHandshakeExitUnprovenError } from './codex-app-server-handshake-exit-proof' -import { terminateProviderProcessTree } from '../provider-process/provider-process-teardown' -import { waitForProcessExitUntil } from '../provider-process/provider-process-exit-deadline' import { CodexAppServerTimeoutError, CodexAppServerUnsupportedError @@ -52,44 +47,26 @@ export async function openCodexAppServerConnection( handlers: CodexAppServerConnectionHandlers = {}, spawnImpl: typeof spawnProcess = spawnProcess ): Promise { - const spawnSpec = createProviderSpawnSpec(launch, process.env, process.platform) - const child = spawnImpl(spawnSpec) - - function terminateProcessTree(): Promise { - // The supervisor and provider own separate POSIX groups so the supervisor can prove the - // provider group empty before relaying its exit. Forced wrapper teardown uses descendant proof. - return terminateProviderProcessTree(child, { site: 'codex-app-server-teardown' }) - } + const managed = spawnManagedProviderProcess(launch, { + spawnImpl, + site: 'codex-app-server-teardown', + closeEventIsExit: true, + policy: (supervised) => ({ + gracefulExitMs: supervised ? PROVIDER_SUPERVISOR_MAX_STOP_MS : GRACEFUL_EXIT_MS, + forcedExitMs: FORCED_EXIT_MS + }) + }) + const { child, terminateTree: terminateProcessTree } = managed let stderrTail = '' let nextRequestId = 1 - let exited = false - let exitObserved = false let closing = false let exitReported = false - let processTreeUnproven = false - const exitProof = new RetryableProcessExitProof() /** First terminal cause, or null while the transport is still usable. Set once: * a child that dies reaches us through several listeners, and the specific * first cause is the one worth reporting. */ let terminalError: Error | null = null - let resolveExit = (): void => undefined - const exitPromise = new Promise((resolve) => { - resolveExit = resolve - }) - - function observeExit(): void { - exited = true - exitObserved = true - resolveExit() - } - - child.on('exit', () => { - observeExit() - handleUnexpectedEnd() - }) - function buildExitError(cause?: Error): Error { return buildCodexAppServerExitError(stderrTail, cause) } @@ -115,7 +92,7 @@ export async function openCodexAppServerConnection( // Transport/protocol failures make the connection unusable immediately so // callers do not hang, but recovery must not treat that as a child exit // until the execution host has observed `exit`/`close`. - if (exitObserved && !exitReported) { + if (managed.rootVerdict === 'exited' && !exitReported) { exitReported = true handlers.onExit?.(terminalError, { expected: closing }) } @@ -124,10 +101,7 @@ export async function openCodexAppServerConnection( child.on('error', (error) => { handleUnexpectedEnd(error) }) - child.on('close', () => { - observeExit() - handleUnexpectedEnd() - }) + managed.onExit(() => handleUnexpectedEnd()) child.stderr.setEncoding('utf8').on('data', (chunk: string) => { stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES) }) @@ -172,7 +146,7 @@ export async function openCodexAppServerConnection( } function notify(method: string, params?: Record): void { - if (exited || terminalError) { + if (managed.exited || managed.processless || terminalError) { return } try { @@ -193,7 +167,7 @@ export async function openCodexAppServerConnection( if (terminalError) { return Promise.reject(terminalError) } - if (exited) { + if (managed.rootVerdict === 'exited') { return Promise.reject(buildExitError()) } const id = nextRequestId++ @@ -217,7 +191,13 @@ export async function openCodexAppServerConnection( } function writeResponse(payload: Record): void { - if (exited || terminalError || child.stdin.destroyed || !child.stdin.writable) { + if ( + managed.exited || + managed.processless || + terminalError || + child.stdin.destroyed || + !child.stdin.writable + ) { return } try { @@ -228,32 +208,13 @@ export async function openCodexAppServerConnection( } function close(): Promise { - if (exitObserved) { + if (managed.rootVerdict === 'exited') { return Promise.resolve(true) } closing = true - return exitProof.run(async () => { - try { - child.stdin.end() - } catch { - // Already destroyed; the reap below still runs. - } - if (!exited) { - // The POSIX supervisor stops its own provider group; forcing it any sooner can orphan it. - await waitForProcessExitUntil( - exitPromise, - process.platform === 'win32' ? GRACEFUL_EXIT_MS : PROVIDER_SUPERVISOR_MAX_STOP_MS - ) - if (!exited) { - const treeExited = await terminateProcessTree() - await waitForProcessExitUntil(exitPromise, FORCED_EXIT_MS) - // The lease follows the root, which is gone: a child left behind is reported by the - // owner, and blocks nothing. - processTreeUnproven = !treeExited && exitObserved - } - } + return managed.close().then((verdict) => { dispatcher.failPending(new Error('codex app-server connection closed')) - return exitObserved + return verdict === 'exited' }) } @@ -262,10 +223,10 @@ export async function openCodexAppServerConnection( return child.pid }, get closed() { - return closing || exited || terminalError !== null + return closing || managed.rootVerdict === 'exited' || terminalError !== null }, get processTreeUnproven() { - return processTreeUnproven + return managed.teardownUnproven }, request, notify, diff --git a/src/main/provider-process/managed-provider-process.test.ts b/src/main/provider-process/managed-provider-process.test.ts new file mode 100644 index 000000000000..e1ad246eca4a --- /dev/null +++ b/src/main/provider-process/managed-provider-process.test.ts @@ -0,0 +1,259 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../shared/child-process/run-process' +import { spawnManagedProviderProcess } from './managed-provider-process' +import type { ProviderProcessTree, ProviderProcessVerdict } from './provider-process-close' + +const mocks = vi.hoisted(() => ({ + capture: vi.fn(async () => null), + windowsTree: vi.fn(async () => {}) +})) +vi.mock('../pty-descendant-termination', () => ({ captureDescendantSnapshot: mocks.capture })) +vi.mock('../windows-process-tree-kill', () => ({ terminateWindowsProcessTree: mocks.windowsTree })) + +afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() +}) + +function fakeChild(pid: number | undefined = 9_999_999) { + const child = Object.assign(new EventEmitter(), { + pid, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(() => true) + }) + const spawn = vi.fn(() => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The managed lifecycle reads only events, pid, streams and kill from this fixture. + return child as unknown as ReturnType + }) + return { child, spawn } +} + +function launch(fixture: ReturnType, platform: NodeJS.Platform = 'darwin') { + return spawnManagedProviderProcess( + { command: 'fixture-provider', args: ['serve'], cwd: '/workspace' }, + { + spawnImpl: fixture.spawn, + platform, + site: 'fixture-provider-teardown', + policy: () => ({ gracefulExitMs: 100, forcedExitMs: 50 }) + } + ) +} + +function fakeTree(initial: ProviderProcessVerdict = 'unverifiable') { + let verdict = initial + const tree: ProviderProcessTree = { + capture: vi.fn(async () => {}), + refresh: vi.fn(async () => {}), + reap: vi.fn(async () => verdict), + get treeVerdict() { + return verdict + } + } + return { + tree, + setVerdict: (next: ProviderProcessVerdict) => { + verdict = next + } + } +} + +describe('managed provider process', () => { + it('applies launch environment and uses the supervisor on its execution platform', () => { + const fixture = fakeChild() + const managed = spawnManagedProviderProcess( + { + command: 'fixture-provider', + args: [], + env: { AGENT_HOME: '/pinned' }, + envToDelete: ['SECRET'] + }, + { + spawnImpl: fixture.spawn, + platform: 'darwin', + inheritedEnv: { SECRET: 'inherited', PATH: '/bin' }, + site: 'fixture', + policy: () => ({ gracefulExitMs: 100, forcedExitMs: 50 }) + } + ) + expect(managed.supervised).toBe(true) + expect(fixture.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + detached: true, + env: expect.objectContaining({ AGENT_HOME: '/pinned', PATH: '/bin' }) + }) + ) + expect(fixture.spawn.mock.calls[0][0].env).not.toHaveProperty('SECRET') + expect(managed.rootVerdict).toBe('unverifiable') + }) + + it('observes exit once across exit and close, including a late subscriber', async () => { + const fixture = fakeChild() + const managed = launch(fixture) + const onExit = vi.fn() + managed.onExit(onExit) + fixture.child.emit('exit', 7, 'SIGTERM') + fixture.child.emit('close', 7, 'SIGTERM') + fixture.child.emit('exit', 8, 'SIGKILL') + await managed.exitPromise + expect(onExit).toHaveBeenCalledExactlyOnceWith({ + code: 7, + signal: 'SIGTERM', + processless: false + }) + const late = vi.fn() + managed.onExit(late) + expect(late).toHaveBeenCalledExactlyOnceWith({ code: 7, signal: 'SIGTERM', processless: false }) + await expect(managed.close()).resolves.toBe('exited') + expect(fixture.child.kill).not.toHaveBeenCalled() + }) + + it('proves stdin-close exit without forcing and clears the grace timer', async () => { + vi.useFakeTimers() + const fixture = fakeChild() + const managed = launch(fixture) + fixture.child.stdin.once('finish', () => fixture.child.emit('exit', 0, null)) + const closing = managed.close() + await vi.advanceTimersByTimeAsync(0) + await expect(closing).resolves.toBe('exited') + expect(fixture.child.kill).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('joins a close, returns unverifiable after the kill deadline, and retries on the same child', async () => { + vi.useFakeTimers() + const fixture = fakeChild() + const managed = launch(fixture) + const first = managed.close() + expect(managed.close()).toBe(first) + await vi.advanceTimersByTimeAsync(150) + await expect(first).resolves.toBe('unverifiable') + expect(fixture.child.kill).toHaveBeenCalledWith('SIGKILL') + expect(managed.exited).toBe(false) + fixture.child.kill.mockImplementation(() => { + fixture.child.emit('exit', null, 'SIGKILL') + return true + }) + const second = managed.close() + await vi.advanceTimersByTimeAsync(150) + await expect(second).resolves.toBe('exited') + expect(fixture.spawn).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it('still reports a root exit once after an unconfirmed close', async () => { + vi.useFakeTimers() + const fixture = fakeChild() + const managed = launch(fixture) + const report = vi.fn() + managed.onExit(report) + const close = managed.close() + await vi.advanceTimersByTimeAsync(150) + await expect(close).resolves.toBe('unverifiable') + fixture.child.emit('exit', 0, null) + fixture.child.emit('close', 0, null) + expect(report).toHaveBeenCalledOnce() + await expect(managed.close()).resolves.toBe('exited') + }) + + it('requires error then close for a processless spawn to settle', async () => { + const fixture = fakeChild() + fixture.child.pid = undefined + const managed = launch(fixture) + fixture.child.emit('error', new Error('ENOENT')) + expect(managed.rootVerdict).toBe('unverifiable') + fixture.child.emit('close', null, null) + expect(managed.processless).toBe(true) + expect(managed.exited).toBe(false) + await expect(managed.close()).resolves.toBe('exited') + }) + + it('does not turn a transport close with an existing pid into Claude exit proof', () => { + const fixture = fakeChild() + const managed = launch(fixture) + fixture.child.emit('error', new Error('EPIPE')) + fixture.child.emit('close', 0, null) + expect(managed.exited).toBe(false) + expect(managed.processless).toBe(false) + }) + + it('uses Windows tree teardown and still requires observed root exit', async () => { + vi.useFakeTimers() + const fixture = fakeChild() + const managed = launch(fixture, 'win32') + expect(managed.supervised).toBe(false) + const first = managed.close() + await vi.advanceTimersByTimeAsync(150) + await expect(first).resolves.toBe('unverifiable') + expect(mocks.windowsTree).toHaveBeenCalledWith(fixture.child.pid, { + site: 'fixture-provider-teardown' + }) + expect(fixture.child.kill).not.toHaveBeenCalledWith('SIGTERM') + fixture.child.emit('exit', null, 'SIGKILL') + await expect(managed.close()).resolves.toBe('exited') + }) + + it('preserves a caller requiring tree proof, including live and unverifiable descendants', async () => { + const fixture = fakeChild() + const managed = spawnManagedProviderProcess( + { command: 'fixture-provider', args: [] }, + { + spawnImpl: fixture.spawn, + platform: 'darwin', + site: 'fixture', + policy: () => ({ gracefulExitMs: 100, forcedExitMs: 50, requireTreeExit: true }) + } + ) + const proof = fakeTree() + fixture.child.emit('exit', 0, null) + await expect(managed.close(proof.tree)).resolves.toBe('unverifiable') + proof.setVerdict('live') + await expect(managed.close(proof.tree)).resolves.toBe('live') + proof.setVerdict('exited') + await expect(managed.close(proof.tree)).resolves.toBe('exited') + }) + + it('reports failed tree cleanup separately after observed root exit', async () => { + vi.useFakeTimers() + const fixture = fakeChild() + const managed = launch(fixture, 'win32') + mocks.windowsTree.mockImplementationOnce(async () => { + fixture.child.emit('exit', null, 'SIGKILL') + throw new Error('tree teardown unavailable') + }) + const close = managed.close() + await vi.advanceTimersByTimeAsync(150) + await expect(close).resolves.toBe('exited') + expect(managed.teardownUnproven).toBe(true) + }) + + it('lets a supervised caller signal immediately and waits its configured grace before forcing', async () => { + vi.useFakeTimers() + const fixture = fakeChild() + const managed = spawnManagedProviderProcess( + { command: 'fixture-provider', args: [] }, + { + spawnImpl: fixture.spawn, + platform: 'darwin', + site: 'fixture', + policy: () => ({ gracefulExitMs: 600, forcedExitMs: 50, signalSupervisorOnClose: true }) + } + ) + fixture.child.kill.mockImplementation(() => { + setTimeout(() => fixture.child.emit('exit', 0, 'SIGTERM'), 550) + return true + }) + const close = managed.close() + await vi.advanceTimersByTimeAsync(549) + expect(fixture.child.kill).toHaveBeenCalledExactlyOnceWith('SIGTERM') + expect(managed.exited).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await expect(close).resolves.toBe('exited') + expect(fixture.child.kill).not.toHaveBeenCalledWith('SIGKILL') + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/main/provider-process/managed-provider-process.ts b/src/main/provider-process/managed-provider-process.ts new file mode 100644 index 000000000000..a8c7de8c047a --- /dev/null +++ b/src/main/provider-process/managed-provider-process.ts @@ -0,0 +1,139 @@ +import { spawnProcess } from '../../shared/child-process/run-process' +import { RetryableProcessExitProof } from '../../shared/child-process/retryable-process-exit-proof' +import type { ProviderProcessLaunch } from './provider-process-launch' +import { createProviderSpawnSpec } from './provider-process-supervisor' +import { terminateProviderProcessTree } from './provider-process-teardown' +import { + closeProviderProcess, + type ProviderProcessClosePolicy, + type ProviderProcessTree, + type ProviderProcessVerdict +} from './provider-process-close' + +export type ProviderProcessExit = { + code: number | null + signal: NodeJS.Signals | null + processless: boolean +} + +type ManagedProviderProcessOptions = { + site: string + policy: (supervised: boolean) => ProviderProcessClosePolicy + spawnImpl?: typeof spawnProcess + platform?: NodeJS.Platform + inheritedEnv?: NodeJS.ProcessEnv + /** Preserve callers which accept Node's close event as root exit evidence. */ + closeEventIsExit?: boolean +} + +export type ManagedProviderProcess = { + child: ReturnType + supervised: boolean + readonly exited: boolean + readonly processless: boolean + readonly rootVerdict: ProviderProcessVerdict + readonly teardownUnproven: boolean + readonly exitPromise: Promise + onExit(listener: (exit: ProviderProcessExit) => void): void + terminateTree(): Promise + close(tree?: ProviderProcessTree): Promise +} + +/** One child owns its exit observation and every retry of an unconfirmed close. */ +export function spawnManagedProviderProcess( + launch: ProviderProcessLaunch, + options: ManagedProviderProcessOptions +): ManagedProviderProcess { + const platform = options.platform ?? process.platform + const spec = createProviderSpawnSpec(launch, options.inheritedEnv ?? process.env, platform) + const child = (options.spawnImpl ?? spawnProcess)({ + program: spec.program, + args: spec.args, + cwd: spec.cwd, + env: spec.env, + detached: spec.detached, + stdio: ['pipe', 'pipe', 'pipe'] + }) + const policy = options.policy(spec.supervised) + const listeners = new Set<(exit: ProviderProcessExit) => void>() + let observed: ProviderProcessExit | null = null + let spawnFailed = false + let teardownUnproven = false + const exitProof = new RetryableProcessExitProof( + (verdict) => verdict === 'exited' + ) + let resolveExit = (): void => {} + const exitPromise = new Promise((resolve) => { + resolveExit = resolve + }) + const observeExit = (exit: ProviderProcessExit): void => { + if (observed) { + return + } + observed = exit + resolveExit() + for (const listener of listeners) { + listener(exit) + } + listeners.clear() + } + child.on('exit', (code, signal) => observeExit({ code, signal, processless: false })) + child.on('error', () => { + spawnFailed ||= child.pid === undefined + }) + child.on('close', (code, signal) => { + const processless = spawnFailed && child.pid === undefined + if (processless || options.closeEventIsExit) { + observeExit({ code, signal, processless }) + } + }) + const exited = (): boolean => observed !== null + const terminateTree = (): Promise => + terminateProviderProcessTree(child, { site: options.site, platform }) + + return { + child, + supervised: spec.supervised, + exitPromise, + get exited() { + return observed !== null && !observed.processless + }, + get processless() { + return observed?.processless ?? false + }, + get rootVerdict() { + return observed ? 'exited' : 'unverifiable' + }, + get teardownUnproven() { + return teardownUnproven && observed !== null + }, + onExit(listener) { + if (observed) { + listener(observed) + } else { + listeners.add(listener) + } + }, + terminateTree, + close(tree) { + if (observed && !policy.requireTreeExit) { + return Promise.resolve('exited') + } + return exitProof.run(async () => { + const result = await closeProviderProcess({ + child, + exitPromise, + exited, + supervised: spec.supervised, + policy, + tree, + terminateTree + }) + if (result.teardownAccepted !== undefined) { + teardownUnproven = !result.teardownAccepted + } + return result.verdict + }) + } + } +} diff --git a/src/main/provider-process/provider-process-close.ts b/src/main/provider-process/provider-process-close.ts new file mode 100644 index 000000000000..5f3e798cfa22 --- /dev/null +++ b/src/main/provider-process/provider-process-close.ts @@ -0,0 +1,74 @@ +import type { SpawnedProcess } from '../../shared/child-process/run-process' +import { waitForProcessExitUntil } from './provider-process-exit-deadline' + +export type ProviderProcessVerdict = 'live' | 'unverifiable' | 'exited' + +export type ProviderProcessTree = { + capture(): Promise + refresh?: () => Promise + reap(): Promise + readonly treeVerdict: ProviderProcessVerdict +} + +export type ProviderProcessClosePolicy = { + gracefulExitMs: number + forcedExitMs: number + signalSupervisorOnClose?: boolean + requireTreeExit?: boolean +} + +export type ProviderProcessCloseInput = { + child: Pick + exitPromise: Promise + exited: () => boolean + supervised?: boolean + policy: ProviderProcessClosePolicy + tree?: ProviderProcessTree + terminateTree: () => Promise +} + +export type ProviderProcessCloseResult = { + verdict: ProviderProcessVerdict + /** Acceptance of the fallback teardown is separate from observed process exit. */ + teardownAccepted?: boolean +} + +/** The supervisor owns the POSIX signal ladder; its wrapper must outlive that ladder. */ +export async function closeProviderProcess( + input: ProviderProcessCloseInput +): Promise { + const { child, policy, tree } = input + await tree?.capture() + try { + child.stdin.end() + } catch { + // A broken pipe still owes the reap. + } + if (input.supervised && policy.signalSupervisorOnClose && !input.exited()) { + child.kill('SIGTERM') + } + let reaped = false + let teardownAccepted: boolean | undefined + if (!input.exited()) { + await waitForProcessExitUntil(input.exitPromise, policy.gracefulExitMs) + if (!input.exited()) { + reaped = true + await tree?.refresh?.() + if (tree) { + await tree.reap() + } else { + teardownAccepted = await input.terminateTree() + } + await waitForProcessExitUntil(input.exitPromise, policy.forcedExitMs) + } + } + if (!reaped && input.exited() && tree && tree.treeVerdict !== 'exited') { + await tree.reap() + } + const verdict = !input.exited() + ? 'unverifiable' + : policy.requireTreeExit + ? (tree?.treeVerdict ?? 'unverifiable') + : 'exited' + return { verdict, ...(teardownAccepted === undefined ? {} : { teardownAccepted }) } +} diff --git a/src/shared/child-process/retryable-process-exit-proof.ts b/src/shared/child-process/retryable-process-exit-proof.ts index f8c3a89de316..117f05fa6cd1 100644 --- a/src/shared/child-process/retryable-process-exit-proof.ts +++ b/src/shared/child-process/retryable-process-exit-proof.ts @@ -1,15 +1,19 @@ -export class RetryableProcessExitProof { - private inFlight: Promise | null = null +export class RetryableProcessExitProof { + private inFlight: Promise | null = null - run(proveExit: () => Promise): Promise { + constructor( + private readonly isProven: (result: Result) => boolean = (result) => result === true + ) {} + + run(proveExit: () => Promise): Promise { if (this.inFlight) { return this.inFlight } const attempt = proveExit() this.inFlight = attempt void attempt.then( - (proven) => { - if (!proven) { + (result) => { + if (!this.isProven(result)) { this.clear(attempt) } }, @@ -18,7 +22,7 @@ export class RetryableProcessExitProof { return attempt } - private clear(attempt: Promise): void { + private clear(attempt: Promise): void { if (this.inFlight === attempt) { this.inFlight = null } From 1835ad9ad7834f79f92e711116b3e343ad43ac13 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:25:49 -0700 Subject: [PATCH 073/269] Find the mobile result box by walking to its View, and type the unverified-ending block --- mobile/src/session/MobileNativeChatMessage.test.ts | 7 ++++++- src/shared/native-chat-tool-run-outcome.test.ts | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index dbaedb660567..e3a2ae2b4e19 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -222,7 +222,12 @@ describe('MobileNativeChatMessage', () => { const output = tree.root .findAllByType('Text' as never) .find((node) => node.children.join('') === 'partial') - return output?.parent?.props.style + // The tint is on the result box: the nearest View around the output text. + let box = output?.parent ?? null + while (box && box.type !== ('View' as never)) { + box = box.parent + } + return box?.props.style } it('shows the output a stop cut short without the error tint', () => { diff --git a/src/shared/native-chat-tool-run-outcome.test.ts b/src/shared/native-chat-tool-run-outcome.test.ts index 2638ee09bc9e..cb91bef09d3e 100644 --- a/src/shared/native-chat-tool-run-outcome.test.ts +++ b/src/shared/native-chat-tool-run-outcome.test.ts @@ -133,7 +133,13 @@ describe('nativeChatToolRunOutcome', () => { }) it('counts a call an unverified end closed as failed until a proof corrects it', () => { - const block: NativeChatBlock = { ...call('a', 'failed'), endedAs: 'unverifiable' } + const block: NativeChatBlock = { + type: 'tool-call', + name: 'shell', + input: { command: 'a' }, + state: 'failed', + endedAs: 'unverifiable' + } expect(nativeChatToolRunOutcome([block], {})).toEqual({ failedCallCount: 1, interruptedCallCount: 0, From b5be93c1ffb011b2eb273823a07ced9f6ea7db93 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:26:26 -0700 Subject: [PATCH 074/269] Preserve synchronous stdin closure before the grace wait --- src/main/provider-process/managed-provider-process.test.ts | 1 + src/main/provider-process/provider-process-close.ts | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/provider-process/managed-provider-process.test.ts b/src/main/provider-process/managed-provider-process.test.ts index e1ad246eca4a..d1b1e12d1962 100644 --- a/src/main/provider-process/managed-provider-process.test.ts +++ b/src/main/provider-process/managed-provider-process.test.ts @@ -118,6 +118,7 @@ describe('managed provider process', () => { const managed = launch(fixture) fixture.child.stdin.once('finish', () => fixture.child.emit('exit', 0, null)) const closing = managed.close() + expect(fixture.child.stdin.writableEnded).toBe(true) await vi.advanceTimersByTimeAsync(0) await expect(closing).resolves.toBe('exited') expect(fixture.child.kill).not.toHaveBeenCalled() diff --git a/src/main/provider-process/provider-process-close.ts b/src/main/provider-process/provider-process-close.ts index 5f3e798cfa22..2543dcd410c3 100644 --- a/src/main/provider-process/provider-process-close.ts +++ b/src/main/provider-process/provider-process-close.ts @@ -11,6 +11,7 @@ export type ProviderProcessTree = { } export type ProviderProcessClosePolicy = { + /** Must cover the supervisor's own stop bound when supervising a real child. */ gracefulExitMs: number forcedExitMs: number signalSupervisorOnClose?: boolean @@ -38,7 +39,9 @@ export async function closeProviderProcess( input: ProviderProcessCloseInput ): Promise { const { child, policy, tree } = input - await tree?.capture() + if (tree) { + await tree.capture() + } try { child.stdin.end() } catch { From 5967576398acd68395e91a39f71afeef2ba43a2f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:45:48 -0700 Subject: [PATCH 075/269] Walk to the mobile result box without a type assertion --- mobile/src/session/MobileNativeChatMessage.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index e3a2ae2b4e19..87d527e5171a 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -224,7 +224,7 @@ describe('MobileNativeChatMessage', () => { .find((node) => node.children.join('') === 'partial') // The tint is on the result box: the nearest View around the output text. let box = output?.parent ?? null - while (box && box.type !== ('View' as never)) { + while (box && String(box.type) !== 'View') { box = box.parent } return box?.props.style From 7a245c45af9e71e426408aafe19976697aee69e9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:58:03 -0700 Subject: [PATCH 076/269] Keep the stacked Claude adapter within the module size limit --- .../claude-structured-event-delivery.ts | 44 +++++++++++++++++++ .../claude-structured-session-adapter.ts | 42 +++++------------- ...claude-structured-session-startup-state.ts | 10 +++++ 3 files changed, 65 insertions(+), 31 deletions(-) create mode 100644 src/main/claude/claude-structured-event-delivery.ts diff --git a/src/main/claude/claude-structured-event-delivery.ts b/src/main/claude/claude-structured-event-delivery.ts new file mode 100644 index 000000000000..6db9f38b48a3 --- /dev/null +++ b/src/main/claude/claude-structured-event-delivery.ts @@ -0,0 +1,44 @@ +import { claudePromptCardWritten } from './claude-child-work-evidence' +import type { + ClaudeSession, + ClaudeStructuredSessionAdapterDeps, + ClaudeStructuredSessionEvent +} from './claude-structured-session-state' + +type ClaudeEventDelivery = { + session: ClaudeSession | null + event: ClaudeStructuredSessionEvent + deps: Pick + publishChildWork: ( + sessionId: string, + session?: ClaudeSession | null, + message?: Record | null + ) => void +} + +export function emitClaudeStructuredSessionEvent({ + session, + event, + deps, + publishChildWork +}: ClaudeEventDelivery): void { + // Host evidence follows the journal; the tracker's roster remains available to contract tests. + if (event.type === 'ended') { + session?.childWork.clear() + session?.backgroundTasks.clear() + } else if (event.type === 'message') { + session?.childWork.observe(event.message) + session?.backgroundTasks.observe(event.message, event.startsTurn === true) + } else if (event.type === 'prompt-cancelled') { + // Free the child before its withdrawn card is written closed. + publishChildWork(event.sessionId, session) + } + if (event.type === 'message' && session?.commands.observe(event.message)) { + session.events?.publish() + } + session?.translator?.handle(event) + deps.onEvent?.(event) + publishChildWork(event.sessionId, session, event.type === 'message' ? event.message : null) + // A prompt blocks its child only after the journal has written its card. + void claudePromptCardWritten(session, event)?.then(() => publishChildWork(event.sessionId)) +} diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 692f8dbeb99f..94720345f33c 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -14,7 +14,7 @@ import { supportsClaudeStructuredLocation } from './claude-structured-location-s import { setClaudeStructuredSessionOption } from './claude-structured-options' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' import { - claudeStartupFailureFact, + awaitClaudeSessionStarted, claudeStartupSettledWithin } from './claude-structured-session-startup-state' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' @@ -40,7 +40,8 @@ import { } from './claude-structured-session-exit-lifecycle' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { resolveClaudeProviderHistoryWindow } from './claude-structured-history-window' -import { claudePromptCardWritten, drainClaudeChildWork } from './claude-child-work-evidence' +import { drainClaudeChildWork } from './claude-child-work-evidence' +import { emitClaudeStructuredSessionEvent } from './claude-structured-event-delivery' import { answerClaudeStructuredPrompt, cancelClaudeStructuredTurn, @@ -135,14 +136,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda /** Resolves once a published session's startup has landed, faulted, or been ended by a close; * with the reason when it did not land. */ - awaitStarted = async (sessionId: string): Promise => { - const session = this.sessions.get(sessionId) - if (!session) { - return - } - await session.startup.settled - return claudeStartupFailureFact(session) ?? undefined - } + awaitStarted = (sessionId: string): Promise => + awaitClaudeSessionStarted(this.sessions.get(sessionId)) /** Restart reconciliation reads the transcript a resume replays; these maps track liveness. */ providerHistoryWindow: NonNullable = ( @@ -156,27 +151,12 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda }) private emit(session: ClaudeSession | null, event: ClaudeStructuredSessionEvent): void { - // The host's child records, fed by the decoder's evidence drained below, are what every surface - // and every Stop reads; the tracker's roster is kept only for tests that compare the two. - if (event.type === 'ended') { - session?.childWork.clear() - session?.backgroundTasks.clear() - } else if (event.type === 'message') { - session?.childWork.observe(event.message) - session?.backgroundTasks.observe(event.message, event.startsTurn === true) - } else if (event.type === 'prompt-cancelled') { - // A withdrawn request frees its child before its card closes: the journal may take that - // write, and publish it, as it is submitted. - this.publishChildWork(event.sessionId, session) - } - if (event.type === 'message' && session?.commands.observe(event.message)) { - session.events?.publish() - } - session?.translator?.handle(event) - this.deps.onEvent?.(event) - this.publishChildWork(event.sessionId, session, event.type === 'message' ? event.message : null) - // A subagent's card holds it waiting only once its row is written: its wait goes out after. - void claudePromptCardWritten(session, event)?.then(() => this.publishChildWork(event.sessionId)) + emitClaudeStructuredSessionEvent({ + session, + event, + deps: this.deps, + publishChildWork: (id, child, message) => this.publishChildWork(id, child, message) + }) } /** After the journal handled the frame, which republished the parent's own row: the host never diff --git a/src/main/claude/claude-structured-session-startup-state.ts b/src/main/claude/claude-structured-session-startup-state.ts index cf44bd984abd..f0d3a252e20d 100644 --- a/src/main/claude/claude-structured-session-startup-state.ts +++ b/src/main/claude/claude-structured-session-startup-state.ts @@ -31,6 +31,16 @@ export function claudeStartupFailureFact(session: ClaudeSession): SubmissionReje : null } +export async function awaitClaudeSessionStarted( + session: ClaudeSession | undefined +): Promise { + if (!session) { + return + } + await session.startup.settled + return claudeStartupFailureFact(session) ?? undefined +} + /** Resolves when startup lands or `timeoutMs` passes; a stuck start then refuses the write as before. */ export function claudeStartupSettledWithin( session: ClaudeSession | undefined, From d47bf9d8d6124627fb97967c5b5877eb7f9b295b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:10:59 -0700 Subject: [PATCH 077/269] Handle nullable provider stdin and processless fixture identity --- src/main/provider-process/managed-provider-process.test.ts | 7 +++---- src/main/provider-process/provider-process-close.ts | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/provider-process/managed-provider-process.test.ts b/src/main/provider-process/managed-provider-process.test.ts index d1b1e12d1962..591b417eabfa 100644 --- a/src/main/provider-process/managed-provider-process.test.ts +++ b/src/main/provider-process/managed-provider-process.test.ts @@ -17,9 +17,9 @@ afterEach(() => { vi.clearAllMocks() }) -function fakeChild(pid: number | undefined = 9_999_999) { +function fakeChild(pid: number | null = 9_999_999) { const child = Object.assign(new EventEmitter(), { - pid, + pid: pid ?? undefined, stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), @@ -162,8 +162,7 @@ describe('managed provider process', () => { }) it('requires error then close for a processless spawn to settle', async () => { - const fixture = fakeChild() - fixture.child.pid = undefined + const fixture = fakeChild(null) const managed = launch(fixture) fixture.child.emit('error', new Error('ENOENT')) expect(managed.rootVerdict).toBe('unverifiable') diff --git a/src/main/provider-process/provider-process-close.ts b/src/main/provider-process/provider-process-close.ts index 2543dcd410c3..333d6af16e1d 100644 --- a/src/main/provider-process/provider-process-close.ts +++ b/src/main/provider-process/provider-process-close.ts @@ -43,7 +43,7 @@ export async function closeProviderProcess( await tree.capture() } try { - child.stdin.end() + child.stdin?.end() } catch { // A broken pipe still owes the reap. } From 74a648c1e1bef767f66eab6b44fe3779bac6fc10 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:27:44 -0700 Subject: [PATCH 078/269] Preserve close-time cleanup diagnostics after a late provider exit --- .../managed-provider-process.test.ts | 13 +++++++++++++ .../provider-process/managed-provider-process.ts | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/provider-process/managed-provider-process.test.ts b/src/main/provider-process/managed-provider-process.test.ts index 591b417eabfa..de1435711912 100644 --- a/src/main/provider-process/managed-provider-process.test.ts +++ b/src/main/provider-process/managed-provider-process.test.ts @@ -231,6 +231,19 @@ describe('managed provider process', () => { expect(managed.teardownUnproven).toBe(true) }) + it('keeps the cleanup diagnostic tied to the close that finished before a late root exit', async () => { + vi.useFakeTimers() + const fixture = fakeChild() + const managed = launch(fixture, 'win32') + mocks.windowsTree.mockRejectedValueOnce(new Error('tree teardown unavailable')) + const close = managed.close() + await vi.advanceTimersByTimeAsync(150) + await expect(close).resolves.toBe('unverifiable') + fixture.child.emit('exit', null, 'SIGKILL') + await expect(managed.close()).resolves.toBe('exited') + expect(managed.teardownUnproven).toBe(false) + }) + it('lets a supervised caller signal immediately and waits its configured grace before forcing', async () => { vi.useFakeTimers() const fixture = fakeChild() diff --git a/src/main/provider-process/managed-provider-process.ts b/src/main/provider-process/managed-provider-process.ts index a8c7de8c047a..a3c37182c88e 100644 --- a/src/main/provider-process/managed-provider-process.ts +++ b/src/main/provider-process/managed-provider-process.ts @@ -32,6 +32,7 @@ export type ManagedProviderProcess = { readonly exited: boolean readonly processless: boolean readonly rootVerdict: ProviderProcessVerdict + /** Cleanup failed in a close that observed the root exit. */ readonly teardownUnproven: boolean readonly exitPromise: Promise onExit(listener: (exit: ProviderProcessExit) => void): void @@ -130,7 +131,7 @@ export function spawnManagedProviderProcess( terminateTree }) if (result.teardownAccepted !== undefined) { - teardownUnproven = !result.teardownAccepted + teardownUnproven = !result.teardownAccepted && result.verdict === 'exited' } return result.verdict }) From 01bd0385a1bb4dc3c667d27c8f771ad3cf320508 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:37:45 -0700 Subject: [PATCH 079/269] Settle ACP background tasks from live and replayed evidence --- src/main/acp/acp-background-task-timeline.ts | 13 +- src/main/acp/acp-dialects/acp-dialect.ts | 11 +- .../acp/acp-dialects/grok-background-tasks.ts | 41 ++- src/main/acp/acp-session-update.ts | 14 +- ...-timeline-background-task-evidence.test.ts | 305 ++++++++++++++++++ .../acp/acp-timeline-background-tasks.test.ts | 20 +- src/main/acp/acp-timeline-translator.ts | 5 +- src/main/acp/acp-tool-timeline.ts | 30 +- src/main/acp/fixtures/s6-background.jsonl | 7 + 9 files changed, 417 insertions(+), 29 deletions(-) create mode 100644 src/main/acp/acp-timeline-background-task-evidence.test.ts diff --git a/src/main/acp/acp-background-task-timeline.ts b/src/main/acp/acp-background-task-timeline.ts index 46cfd3117639..0156a10c223f 100644 --- a/src/main/acp/acp-background-task-timeline.ts +++ b/src/main/acp/acp-background-task-timeline.ts @@ -31,16 +31,23 @@ export class AcpBackgroundTaskTimeline { translate( updates: AcpBackgroundTaskUpdate[], - join: ProviderTimelineJoin + join: ProviderTimelineJoin, + replay = false ): ProviderTimelineEvent[] { return updates.map((update) => { + const { fallbackLabel, ...fields } = update const previous = this.snapshots.get(update.taskId) ?? this.persisted(update.taskId) const block: NativeChatBackgroundTaskBlock = { type: 'background-task', kind: 'unknown', - label: update.taskId, ...previous, - ...update + ...fields, + label: update.label ?? previous?.label ?? fallbackLabel ?? update.taskId, + // A historical launch proves no current liveness; explicit outcomes still settle it. + state: + replay && ['working', 'monitoring', 'waiting'].includes(update.state) + ? 'unverifiable' + : update.state } for (const key of ['label', 'summary', 'error', 'outputFile'] as const) { const text = block[key] diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 4b7d15a32c93..9fed58f93789 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -1,4 +1,5 @@ import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' +import type { AgentJournalToolCallItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { NativeChatBackgroundTaskBlock } from '../../../shared/native-chat-types' import type { ProviderTimelineRequestBody } from '../../native-chat/agent-session-timeline/provider-timeline-event' @@ -10,7 +11,10 @@ export type AcpRequestPresentation = { } export type AcpBackgroundTaskUpdate = Pick & - Partial> + Partial> & { + /** Used only until a provider description is known. */ + fallbackLabel?: string + } export type AcpDialectNotification = | { disposition: 'ignore' } @@ -29,7 +33,10 @@ export type AcpDialectNotification = export type AcpDialect = { injectedPromptIdentity?: true toolName?(update: ToolCallUpdate): string | undefined - toolBackgroundTasks?(update: ToolCallUpdate): AcpBackgroundTaskUpdate[] + toolBackgroundTasks?( + update: ToolCallUpdate, + tool: AgentJournalToolCallItem + ): AcpBackgroundTaskUpdate[] notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined contextWindow?(models: unknown): number | undefined request?(method: string, params: unknown): AcpRequestPresentation | undefined diff --git a/src/main/acp/acp-dialects/grok-background-tasks.ts b/src/main/acp/acp-dialects/grok-background-tasks.ts index 1477529a45a5..3c19c5e49669 100644 --- a/src/main/acp/acp-dialects/grok-background-tasks.ts +++ b/src/main/acp/acp-dialects/grok-background-tasks.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import type { AgentJournalToolCallItem } from '../../../shared/agent-session-journal-types' import { isSettledBackgroundTaskState, normalizeBackgroundTaskKind @@ -40,7 +41,8 @@ function snapshot( ): AcpBackgroundTaskUpdate { const kind = task.kind ?? task.task_type const monitor = kind === 'monitor' || task.monitor_description != null - const label = task.monitor_description ?? task.description ?? task.display_command ?? task.command + const label = task.monitor_description?.trim() || task.description?.trim() + const fallbackLabel = task.display_command?.trim() || task.command?.trim() return { taskId: task.task_id, state, @@ -54,23 +56,46 @@ function snapshot( : normalizeBackgroundTaskKind(kind ?? 'unknown') } : {}), - ...(label === undefined ? {} : { label }), + ...(label ? { label } : fallbackLabel ? { fallbackLabel } : {}), ...(task.output_file === undefined ? {} : { outputFile: task.output_file }), - ...(task.summary === undefined && - task.output === undefined && - !isSettledBackgroundTaskState(state) + ...(task.summary === undefined && !isSettledBackgroundTaskState(state) ? {} - : { summary: task.summary ?? task.output ?? '' }), + : { summary: task.summary ?? '' }), ...(task.error === undefined && !isSettledBackgroundTaskState(state) ? {} : { error: task.error ?? '' }) } } -export function grokToolBackgroundTasks(update: ToolCallUpdate): AcpBackgroundTaskUpdate[] { +const taskInputSchema = z.object({ + description: z.string().optional(), + command: z.string().optional(), + task_id: z.string().trim().min(1).optional(), + task_ids: z.array(z.string().trim().min(1)).optional() +}) + +export function grokToolBackgroundTasks( + update: ToolCallUpdate, + tool: AgentJournalToolCallItem +): AcpBackgroundTaskUpdate[] { + const input = taskInputSchema.safeParse(tool.input) + if (tool.name === 'kill_command_or_subagent' && update.status === 'completed' && input.success) { + // Inference: a successful kill call settles its named tasks without a completion notice. + return [ + ...new Set([ + ...(input.data.task_ids ?? []), + ...(input.data.task_id ? [input.data.task_id] : []) + ]) + ].map((taskId) => ({ taskId, state: 'idle', summary: '', error: '' })) + } const parsed = startedOutputSchema.safeParse(update.rawOutput) return parsed.success - ? [{ ...snapshot(parsed.data, 'working'), parentToolUseId: update.toolCallId }] + ? [ + { + ...snapshot({ ...(input.success ? input.data : {}), ...parsed.data }, 'working'), + parentToolUseId: update.toolCallId + } + ] : [] } diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index aa891d2e100f..c398c129dcd6 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -71,11 +71,15 @@ export function acpSessionUpdate( ] : [] case 'tool_call': - case 'tool_call_update': - return [ - ...tools.translate(update, dialect, join.join), - ...backgroundTasks.translate(dialect.toolBackgroundTasks?.(update) ?? [], join.join) - ] + case 'tool_call_update': { + const events = tools.translate(update, dialect, join.join) + const tool = events[0] + const tasks = + tool && 'body' in tool && tool.body?.kind === 'tool-call' + ? (dialect.toolBackgroundTasks?.(update, tool.body) ?? []) + : [] + return [...events, ...backgroundTasks.translate(tasks, join.join, replay)] + } case 'plan': return [ { diff --git a/src/main/acp/acp-timeline-background-task-evidence.test.ts b/src/main/acp/acp-timeline-background-task-evidence.test.ts new file mode 100644 index 000000000000..023f6d6d5860 --- /dev/null +++ b/src/main/acp/acp-timeline-background-task-evidence.test.ts @@ -0,0 +1,305 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { ToolCallUpdate } from './generated/acp-protocol.generated' +import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' + +afterEach(closeProviderTimelineRigs) + +const taskId = '01a10366-1e7f-7191-bcd1-fac24585851f' +type Fixture = Awaited> + +async function taskRows(fixture: Fixture) { + return (await fixture.rig.rows()).flatMap((row) => { + const block = + row.body.kind === 'message' ? row.body.blocks.find(isBackgroundTaskBlock) : undefined + return block ? [{ row, block }] : [] + }) +} + +async function launch(fixture: Fixture) { + const frames = await readAcpFixture('s6-background') + const completion = frames.findIndex((frame) => frame.message.method === '_x.ai/task_completed') + expect(completion).toBeGreaterThan(0) + await fixture.feed(frames.slice(0, completion)) + return frames.slice(completion) +} + +function tool(fixture: Fixture, update: ToolCallUpdate, replay = false) { + const events = fixture.lane().notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: replay ? 'historic-turn' : 'kill-turn', isReplay: replay }, + update: { sessionUpdate: 'tool_call_update', ...update } + }, + 2000 + ) + for (const event of events) { + expect(fixture.rig.assembler.apply(event).admission, update.toolCallId).toMatchObject({ + accepted: true + }) + } +} + +function taskNotice(fixture: Fixture, id: string, replay = false, completed = false) { + fixture.apply( + fixture.lane().notification( + completed ? '_x.ai/task_completed' : '_x.ai/task_backgrounded', + { + sessionId: 'session-1', + _meta: { isReplay: replay }, + update: completed + ? { sessionUpdate: 'task_completed', task_snapshot: { task_id: id, exit_code: 0 } } + : { sessionUpdate: 'task_backgrounded', task_id: id, command: 'sleep 99' } + }, + 2001 + ) + ) +} + +describe('background task outcome evidence', () => { + it('SF1 keeps multiline stdout out of the task sentence and preserves the description across frames', async () => { + const fixture = await openAcpFixtureRig() + await launch(fixture) + const output = `PASS a.test.js\nPASS b.test.js\n${'log line\n'.repeat(3000)}` + fixture.apply( + fixture.lane().notification( + '_x.ai/task_completed', + { + sessionId: 'session-1', + update: { + sessionUpdate: 'task_completed', + task_snapshot: { + task_id: taskId, + command: 'npm test', + output, + exit_code: 0 + } + } + }, + 3000 + ) + ) + const [task] = await taskRows(fixture) + expect(task?.block).toMatchObject({ + state: 'done', + label: 'Sleep then write bg-marker.txt', + summary: '' + }) + expect(task && backgroundTaskFallbackText(task.block)).toBe( + 'Background command "Sleep then write bg-marker.txt" finished' + ) + expect(task?.row.body).toMatchObject({ + blocks: [ + { type: 'text', text: 'Background command "Sleep then write bg-marker.txt" finished' }, + expect.objectContaining({ type: 'background-task', state: 'done' }) + ] + }) + expect(JSON.stringify(task?.row.body)).not.toContain('PASS a.test.js') + }) + + it('uses the command when the provider never gives a description', async () => { + const fixture = await openAcpFixtureRig() + taskNotice(fixture, 'unnamed') + taskNotice(fixture, 'unnamed', false, true) + const [task] = await taskRows(fixture) + expect(task && backgroundTaskFallbackText(task.block)).toBe( + 'Background command "sleep 99" finished' + ) + }) + + it('replays the captured task and queue notices into one completed row beside its launch tool', async () => { + const fixture = await openAcpFixtureRig() + const tail = await launch(fixture) + const [before] = await taskRows(fixture) + expect(before?.block.state).toBe('working') + fixture.restart() + await fixture.feed(tail) + const [after] = await taskRows(fixture) + expect(await taskRows(fixture)).toHaveLength(1) + expect(after?.row.itemId).toBe(before?.row.itemId) + expect(after?.row.turnScope).toEqual(before?.row.turnScope) + expect(after?.block).toMatchObject({ + state: 'done', + label: 'Sleep then write bg-marker.txt', + parentToolUseId: 'call-1' + }) + fixture.apply([{ type: 'session.ended', verdict: { state: 'unverifiable' } }]) + expect((await taskRows(fixture))[0]?.block.state).toBe('done') + }) + + it.each(['partial', 'restart', 'eviction'] as const)( + 'SF2 settles named tasks on a completed kill call after %s', + async (boundary) => { + const fixture = await openAcpFixtureRig() + await launch(fixture) + taskNotice(fixture, 'other-task') + const [before] = await taskRows(fixture) + tool(fixture, { + toolCallId: 'kill-1', + status: 'in_progress', + _meta: { 'x.ai/tool': { name: 'kill_command_or_subagent' } }, + rawInput: { task_id: taskId, task_ids: [taskId, 'other-task'] } + }) + await fixture.rig.rows() + if (boundary === 'restart') { + fixture.restart() + } + if (boundary === 'eviction') { + for (let index = 0; index < 70; index += 1) { + // Pressure only the translator cache; the target still goes through the journal. + fixture.lane().notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: 'kill-turn' }, + update: { + sessionUpdate: 'tool_call', + toolCallId: `unrelated-${index}`, + status: 'in_progress', + name: 'read_file', + rawInput: { content: 'x'.repeat(40000) }, + rawOutput: 'y'.repeat(40000) + } + }, + 2000 + ) + } + await fixture.rig.rows() + } + tool(fixture, { toolCallId: 'kill-1', status: 'completed' }) + const tasks = await taskRows(fixture) + expect(tasks.map((task) => task.block.state)).toEqual(['idle', 'idle']) + expect(tasks[0]?.row.itemId).toBe(before?.row.itemId) + expect(tasks[0]?.row.turnScope).toEqual(before?.row.turnScope) + expect(tasks[0] && backgroundTaskFallbackText(tasks[0].block)).toBe( + 'Background command "Sleep then write bg-marker.txt" was stopped' + ) + taskNotice(fixture, taskId) + taskNotice(fixture, taskId, false, true) + expect((await taskRows(fixture))[0]?.block.state).toBe('idle') + } + ) + + it.each(['pending', 'in_progress', 'failed'] as const)( + 'does not infer a stop from a %s kill call', + async (status) => { + const fixture = await openAcpFixtureRig() + await launch(fixture) + tool(fixture, { + toolCallId: 'kill-1', + name: 'kill_command_or_subagent', + status, + rawInput: { task_id: taskId } + }) + expect((await taskRows(fixture))[0]?.block.state).toBe('working') + } + ) + + it.each(['launch', 'notification'] as const)( + 'SF3 treats replayed %s evidence as unverifiable and accepts a later completion', + async (source) => { + const fixture = await openAcpFixtureRig() + fixture.lane().beginLoad() + if (source === 'launch') { + tool( + fixture, + { + toolCallId: 'historic-tool', + status: 'completed', + rawInput: { description: 'Historical task' }, + rawOutput: { + type: 'BackgroundTaskStarted', + task_id: taskId, + task_type: 'bash', + command: 'sleep 99' + } + }, + true + ) + } else { + taskNotice(fixture, taskId, true) + } + fixture.finishLoad() + expect((await taskRows(fixture))[0]?.block.state).toBe('unverifiable') + fixture.restart() + taskNotice(fixture, taskId, false, true) + expect((await taskRows(fixture))[0]?.block.state).toBe('done') + } + ) + + it.each(['notification', 'kill'] as const)( + 'SF3 preserves the explicit %s outcome included in adopted history', + async (completion) => { + const fixture = await openAcpFixtureRig() + fixture.lane().beginLoad() + tool( + fixture, + { + toolCallId: 'historic-tool', + status: 'completed', + rawOutput: { + type: 'BackgroundTaskStarted', + task_id: taskId, + task_type: 'bash', + command: 'sleep 99' + } + }, + true + ) + if (completion === 'notification') { + taskNotice(fixture, taskId, true, true) + } else { + tool( + fixture, + { + toolCallId: 'historic-kill', + name: 'kill_command_or_subagent', + status: 'completed', + rawInput: { task_id: taskId } + }, + true + ) + } + fixture.finishLoad() + expect((await taskRows(fixture))[0]?.block.state).toBe( + completion === 'notification' ? 'done' : 'idle' + ) + } + ) + + it('SF3 adopts a missing task into unfinished journal history without claiming it is live', async () => { + const fixture = await openAcpFixtureRig() + fixture.apply( + fixture.lane().notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: 'historic-turn' }, + update: { sessionUpdate: 'tool_call', toolCallId: 'historic-tool', status: 'in_progress' } + }, + 1000 + ) + ) + await fixture.rig.rows() + fixture.lane().beginLoad() + tool( + fixture, + { + toolCallId: 'historic-tool', + status: 'completed', + rawOutput: { + type: 'BackgroundTaskStarted', + task_id: taskId, + task_type: 'bash', + command: 'sleep 99' + } + }, + true + ) + fixture.finishLoad() + expect((await taskRows(fixture))[0]?.block.state).toBe('unverifiable') + }) +}) diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts index a25a37020093..eb1c926f251b 100644 --- a/src/main/acp/acp-timeline-background-tasks.test.ts +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -9,6 +9,12 @@ afterEach(closeProviderTimelineRigs) const taskId = '01a10366-1e7f-7191-bcd1-fac24585851f' +async function runningFrames() { + return (await readAcpFixture('s6-background')).filter( + (frame) => frame.message.method !== '_x.ai/task_completed' + ) +} + async function taskRows(fixture: Awaited>) { return (await fixture.rig.rows()).flatMap((row) => { if (row.body.kind !== 'message') { @@ -53,7 +59,7 @@ describe('Grok background tasks through the shared timeline', () => { 'renders the recorded task outside turn settlement and marks it unverifiable on session %s after restart', async (boundary) => { const fixture = await openAcpFixtureRig() - await fixture.feed(await readAcpFixture('s6-background')) + await fixture.feed(await runningFrames()) const [before] = await taskRows(fixture) expect(before?.block).toMatchObject({ taskId, @@ -96,13 +102,13 @@ describe('Grok background tasks through the shared timeline', () => { 'joins %s task notifications and the tool result into one row and keeps its origin after restart', async (prefix) => { const fixture = await openAcpFixtureRig() - const frames = await readAcpFixture('s6-background') + const frames = await runningFrames() await fixture.feed(frames.slice(0, 5)) fixture.apply(fixture.lane().notification(`${prefix}task_backgrounded`, started(), 1010)) await fixture.feed(frames.slice(5)) const [before] = await taskRows(fixture) expect(await taskRows(fixture)).toHaveLength(1) - expect(before?.block.label).toBe('sleep 15; echo bg-done > bg-marker.txt') + expect(before?.block.label).toBe('Sleep then write bg-marker.txt') fixture.restart() fixture.apply( fixture @@ -118,7 +124,7 @@ describe('Grok background tasks through the shared timeline', () => { expect(after?.row.turnScope).toEqual(before?.row.turnScope) expect(after?.block).toMatchObject({ state: 'done', - summary: 'Finished', + summary: '', label: before?.block.label, parentToolUseId: 'call-1' }) @@ -138,7 +144,7 @@ describe('Grok background tasks through the shared timeline', () => { 'preserves explicit task completion and the provider outcome %j', async (snapshot, state) => { const fixture = await openAcpFixtureRig() - await fixture.feed(await readAcpFixture('s6-background')) + await fixture.feed(await runningFrames()) fixture.apply(fixture.lane().notification('x.ai/task_completed', completed(snapshot), 2000)) expect((await taskRows(fixture))[0]?.block.state).toBe(state) } @@ -146,7 +152,7 @@ describe('Grok background tasks through the shared timeline', () => { it('recovers partial task metadata from the journal after bounded snapshots are evicted', async () => { const fixture = await openAcpFixtureRig() - await fixture.feed(await readAcpFixture('s6-background')) + await fixture.feed(await runningFrames()) for (let index = 0; index < 130; index += 1) { fixture.apply( fixture @@ -168,7 +174,7 @@ describe('Grok background tasks through the shared timeline', () => { it('places a first task notice after restart beside its original tool while a different turn is active', async () => { const fixture = await openAcpFixtureRig() - await fixture.feed((await readAcpFixture('s6-background')).slice(0, 5)) + await fixture.feed((await runningFrames()).slice(0, 5)) fixture.apply( fixture.lane().notification( 'session/update', diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index a99036e1a8c1..1a93fe55dcf9 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -42,7 +42,7 @@ export type AcpTimelineTranslatorOptions = { export class AcpTimelineTranslator { private readonly dialect: AcpDialect private readonly prompts: AcpPromptTurns - private readonly tools = new AcpToolTimeline() + private readonly tools: AcpToolTimeline private readonly backgroundTasks: AcpBackgroundTaskTimeline private readonly messages = new AcpTurnMessages() private readonly started = new BoundedMap({ maxEntries: 128 }) @@ -52,6 +52,7 @@ export class AcpTimelineTranslator { constructor(private readonly options: AcpTimelineTranslatorOptions) { this.dialect = options.dialect ?? GENERIC_ACP_DIALECT + this.tools = new AcpToolTimeline(options.journalItems) this.backgroundTasks = new AcpBackgroundTaskTimeline(options.journalItems, (callId) => this.tools.turn(callId) ) @@ -225,7 +226,7 @@ export class AcpTimelineTranslator { } const join = { thread: this.options.sessionId, ...(turn === undefined ? {} : { turn }) } if (extension?.backgroundTasks) { - events.push(...this.backgroundTasks.translate(extension.backgroundTasks, join)) + events.push(...this.backgroundTasks.translate(extension.backgroundTasks, join, isReplay)) } if (extension?.usage) { events.push(...this.context.update(extension.usage, join)) diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts index 5cde7b29e0b0..1567a871740a 100644 --- a/src/main/acp/acp-tool-timeline.ts +++ b/src/main/acp/acp-tool-timeline.ts @@ -1,6 +1,12 @@ import { createPatch } from 'diff' import { z } from 'zod' -import type { AgentJournalToolCallItem } from '../../shared/agent-session-journal-types' +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalToolCallItem +} from '../../shared/agent-session-journal-types' +import { acpJournalToolTurn } from './acp-journal-turns' +import { providerTimelineKeyPart } from '../native-chat/agent-session-timeline/provider-timeline-identity' import { boundPayload, boundToolInput, @@ -74,12 +80,15 @@ export class AcpToolTimeline { private readonly tools = new Map() private bytes = 0 + constructor(private readonly journalItems: () => readonly AgentJournalRenderItem[] = () => []) {} + translate( update: ToolCallUpdate, dialect: AcpDialect, join: ProviderTimelineJoin ): ProviderTimelineEvent[] { - const previous = this.tools.get(update.toolCallId) + const previous = + this.tools.get(update.toolCallId) ?? this.persisted(update.toolCallId, join.thread) const output = outputText(update) const state = update.status === 'completed' @@ -154,6 +163,23 @@ export class AcpToolTimeline { return this.tools.get(toolCallId)?.turn } + private persisted(callId: string, thread: string | undefined): ToolSnapshot | undefined { + const rows = this.journalItems() + const suffix = `${thread ? `${providerTimelineKeyPart(thread)}/` : ''}${providerTimelineKeyPart(`tool:${callId}`)}` + const row = rows.find((item) => { + const identity = parseAgentJournalItemKey(item.itemId) + return ( + item.body.kind === 'tool-call' && + item.body.callId === callId && + identity?.provider === 'legacy' && + identity.recordId.endsWith(`:${suffix}`) + ) + }) + return row?.body.kind === 'tool-call' + ? { body: row.body, turn: acpJournalToolTurn(rows, callId) } + : undefined + } + end(turn: string): void { for (const [key, snapshot] of this.tools) { if (snapshot.turn === turn) { diff --git a/src/main/acp/fixtures/s6-background.jsonl b/src/main/acp/fixtures/s6-background.jsonl index 927b68df55e2..5b1837955172 100644 --- a/src/main/acp/fixtures/s6-background.jsonl +++ b/src/main/acp/fixtures/s6-background.jsonl @@ -1,11 +1,18 @@ {"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:3"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[{"id":"prompt:p1:3","version":0,"kind":"prompt","text":"Start this shell command as a BACKGROUND task (do not wait for it): sleep 15; echo bg-done > bg-marker.txt . Then immediately end your turn with a one-line reply. When the background task finishes later, tell me.","position":0}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[],"runningPromptId":"prompt:p1:3","runningText":"Start this shell command as a BACKGROUND task (do not wait for it): sleep 15; echo bg-done > bg-marker.txt . Then immediately end your turn with a one-line reply. When the background task finishes later, tell me.","runningKind":"prompt"}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" start"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"run_terminal_command","rawInput":{"command":"sleep 15; echo bg-done > bg-marker.txt","description":"Sleep then write bg-marker.txt","block_until_ms":0},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"execute","title":"Execute `sleep 15; echo bg-done > bg-marker.txt`","content":[{"type":"content","content":{"type":"text","text":"Sleep then write bg-marker.txt"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 15; echo bg-done > bg-marker.txt","description":"Sleep then write bg-marker.txt","is_background":false,"block_until_ms":0},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/task_backgrounded","params":{"sessionId":"session-1","update":{"sessionUpdate":"task_backgrounded","tool_call_id":"call-1","task_id":"01a10366-1e7f-7191-bcd1-fac24585851f","command":"sleep 15; echo bg-done > bg-marker.txt","cwd":"/workspace/project","output_file":"/workspace/file-1","description":"Sleep then write bg-marker.txt"},"_meta":{"eventId":"session-1-24","agentTimestampMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","title":"[bg] sleep 15; echo bg-done > bg-marker.txt (01a10366)","content":[{"type":"content","content":{"type":"text","text":"Background task 01a10366-1e7f-7191-bcd1-fac24585851f started"}}],"rawOutput":{"type":"BackgroundTaskStarted","task_id":"01a10366-1e7f-7191-bcd1-fac24585851f","task_type":"bash","output_file":"/workspace/file-1","status":"running","command":"sleep 15; echo bg-done > bg-marker.txt","summary":"Background task 01a10366-1e7f-7191-bcd1-fac24585851f started","retrieval_hint":"Use get_command_or_subagent_output with task_ids=[\"01a10366-1e7f-7191-bcd1-fac24585851f\"] when you need the output."}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:3","stop_reason":"end_turn","usage":{"inputTokens":69127,"outputTokens":207,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5774}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":69127,"outputTokens":207,"cachedReadTokens":35968,"cacheCreationTokens":0}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[],"runningPromptId":"task-completed-2","runningText":"\nBackground task \"01a10366-1e7f-7191-bcd1-fac24585851f\" completed (exit code: 0).\nDescription: Sleep then write bg-marker.txt | Duration: 15.0s\nUse get_command_or_subagent_output(\"01a10366-1e7f-7191-bcd1-fac24585851f\") to see the full output.\n","runningKind":"prompt"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/task_completed","params":{"sessionId":"session-1","update":{"sessionUpdate":"task_completed","task_snapshot":{"task_id":"01a10366-1e7f-7191-bcd1-fac24585851f","command":"sleep 15; echo bg-done > bg-marker.txt","cwd":"/workspace/project","start_time":{"secs_since_epoch":1791058386,"nanos_since_epoch":559567000},"end_time":{"secs_since_epoch":1791058401,"nanos_since_epoch":567941000},"output":"","output_file":"/workspace/file-1","truncated":false,"output_total_bytes":0,"exit_code":0,"signal":null,"completed":true,"kind":"bash","block_waited":false,"explicitly_killed":false,"kill_result_delivered":false,"owner_session_id":"session-1","description":"Sleep then write bg-marker.txt","is_backgrounded":true},"will_wake":true},"_meta":{"eventId":"session-1-47","agentTimestampMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}},"_meta":{"promptId":"task-completed-2","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" background"}},"_meta":{"promptId":"task-completed-2","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"task-completed-2","stop_reason":"end_turn","usage":{"inputTokens":34928,"outputTokens":219,"cachedReadTokens":34688,"cacheCreationTokens":0},"elapsed_ms":4520}}}} From dec3ecbb3ab55055e7959f875f989dd2e53878c3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:43:12 -0700 Subject: [PATCH 080/269] Honor background task outcomes carried by replayed tool results --- .../acp/acp-dialects/grok-background-tasks.ts | 57 ++++++++- ...-timeline-background-task-evidence.test.ts | 8 +- ...p-timeline-background-task-results.test.ts | 111 ++++++++++++++++++ src/main/acp/acp-tool-timeline.ts | 10 +- 4 files changed, 173 insertions(+), 13 deletions(-) create mode 100644 src/main/acp/acp-timeline-background-task-results.test.ts diff --git a/src/main/acp/acp-dialects/grok-background-tasks.ts b/src/main/acp/acp-dialects/grok-background-tasks.ts index 3c19c5e49669..3726c8b25327 100644 --- a/src/main/acp/acp-dialects/grok-background-tasks.ts +++ b/src/main/acp/acp-dialects/grok-background-tasks.ts @@ -33,8 +33,44 @@ const completedSchema = z.object({ task_snapshot: taskSchema }) const startedOutputSchema = taskSchema.extend({ type: z.literal('BackgroundTaskStarted') }) +const taskResultSchema = taskSchema.extend({ + status: z.string().optional(), + outcome: z.string().optional() +}) +const resultOutputSchema = z.object({ + type: z.enum(['TaskOutput', 'KillTask']), + Result: taskResultSchema.optional(), + MultiResult: z.object({ results: z.array(taskResultSchema) }).optional() +}) type GrokTask = z.infer +function completedState(task: GrokTask): NativeChatBackgroundTaskBlock['state'] { + return task.explicitly_killed + ? 'idle' + : task.signal != null || task.error?.trim() || (task.exit_code != null && task.exit_code !== 0) + ? 'blocked' + : 'done' +} + +function resultState( + task: z.infer +): NativeChatBackgroundTaskBlock['state'] | undefined { + if (task.explicitly_killed || ['killed', 'stopped', 'cancelled'].includes(task.status ?? '')) { + return 'idle' + } + if (task.error?.trim() || ['failed', 'error'].includes(task.status ?? '')) { + return 'blocked' + } + if ( + task.exit_code != null || + task.signal != null || + ['completed', 'success', 'succeeded'].includes(task.status ?? '') + ) { + return completedState(task) + } + return ['pending', 'running'].includes(task.status ?? '') ? 'working' : undefined +} + function snapshot( task: GrokTask, state: NativeChatBackgroundTaskBlock['state'] @@ -78,6 +114,20 @@ export function grokToolBackgroundTasks( update: ToolCallUpdate, tool: AgentJournalToolCallItem ): AcpBackgroundTaskUpdate[] { + const result = resultOutputSchema.safeParse(update.rawOutput) + if (result.success) { + const tasks = + result.data.MultiResult?.results ?? (result.data.Result ? [result.data.Result] : []) + return tasks.flatMap((task) => { + if (result.data.type === 'KillTask') { + return update.status === 'completed' && task.outcome === 'killed' + ? [snapshot(task, 'idle')] + : [] + } + const state = resultState(task) + return state && !task.command?.startsWith('[subagent:') ? [snapshot(task, state)] : [] + }) + } const input = taskInputSchema.safeParse(tool.input) if (tool.name === 'kill_command_or_subagent' && update.status === 'completed' && input.success) { // Inference: a successful kill call settles its named tasks without a completion notice. @@ -125,10 +175,5 @@ export function grokBackgroundTaskNotification( } } const task = update.task_snapshot - const state = task.explicitly_killed - ? 'idle' - : task.signal != null || task.error?.trim() || (task.exit_code != null && task.exit_code !== 0) - ? 'blocked' - : 'done' - return { disposition: 'map', backgroundTasks: [snapshot(task, state)] } + return { disposition: 'map', backgroundTasks: [snapshot(task, completedState(task))] } } diff --git a/src/main/acp/acp-timeline-background-task-evidence.test.ts b/src/main/acp/acp-timeline-background-task-evidence.test.ts index 023f6d6d5860..7b98bcce2a0d 100644 --- a/src/main/acp/acp-timeline-background-task-evidence.test.ts +++ b/src/main/acp/acp-timeline-background-task-evidence.test.ts @@ -157,6 +157,7 @@ describe('background task outcome evidence', () => { _meta: { promptId: 'kill-turn' }, update: { sessionUpdate: 'tool_call', + title: 'Read file', toolCallId: `unrelated-${index}`, status: 'in_progress', name: 'read_file', @@ -278,7 +279,12 @@ describe('background task outcome evidence', () => { { sessionId: 'session-1', _meta: { promptId: 'historic-turn' }, - update: { sessionUpdate: 'tool_call', toolCallId: 'historic-tool', status: 'in_progress' } + update: { + sessionUpdate: 'tool_call', + title: 'Historical launch', + toolCallId: 'historic-tool', + status: 'in_progress' + } }, 1000 ) diff --git a/src/main/acp/acp-timeline-background-task-results.test.ts b/src/main/acp/acp-timeline-background-task-results.test.ts new file mode 100644 index 000000000000..49cba692cd36 --- /dev/null +++ b/src/main/acp/acp-timeline-background-task-results.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' + +afterEach(closeProviderTimelineRigs) + +describe('background task results in adopted history', () => { + it.each([ + ['running', undefined, 'unverifiable'], + ['completed', 0, 'done'], + ['completed', undefined, 'done'], + ['failed', 1, 'blocked'], + ['stopped', undefined, 'idle'], + ['unknown', undefined, 'unverifiable'] + ] as const)( + 'settles replay from a TaskOutput result with status %s and exit code %s', + async (status, exitCode, state) => { + const fixture = await openAcpFixtureRig() + const lane = fixture.lane() + lane.beginLoad() + for (const [index, rawOutput] of [ + { type: 'BackgroundTaskStarted', task_id: 'historic-task', command: 'npm test' }, + { + type: 'TaskOutput', + Result: { + task_id: 'historic-task', + command: 'npm test', + status, + exit_code: exitCode, + output: 'PASS a.test.js\nPASS b.test.js\n' + } + } + ].entries()) { + fixture.apply( + lane.notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { isReplay: true, promptId: 'historic-turn' }, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: `tool-${index}`, + status: 'completed', + rawOutput + } + }, + 1000 + index + ) + ) + } + fixture.finishLoad() + const tasks = (await fixture.rig.rows()).flatMap((row) => + row.body.kind === 'message' ? row.body.blocks.filter(isBackgroundTaskBlock) : [] + ) + expect(tasks).toHaveLength(1) + expect(tasks[0]).toMatchObject({ state, label: 'npm test' }) + expect(tasks[0]?.summary ?? '').not.toContain('PASS') + } + ) + + it('uses each structured kill result rather than inferring a stop for unsuccessful targets', async () => { + const fixture = await openAcpFixtureRig() + const lane = fixture.lane() + for (const id of ['killed-task', 'kept-task']) { + fixture.apply( + lane.notification( + '_x.ai/task_backgrounded', + { + sessionId: 'session-1', + update: { sessionUpdate: 'task_backgrounded', task_id: id, command: 'sleep 99' } + }, + 1000 + ) + ) + } + fixture.apply( + lane.notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: 'kill-turn' }, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'kill-1', + status: 'completed', + name: 'kill_command_or_subagent', + rawInput: { task_ids: ['killed-task', 'kept-task'] }, + rawOutput: { + type: 'KillTask', + MultiResult: { + results: [ + { task_id: 'killed-task', outcome: 'killed' }, + { task_id: 'kept-task', outcome: 'not_killed' } + ] + } + } + } + }, + 1100 + ) + ) + const tasks = (await fixture.rig.rows()).flatMap((row) => + row.body.kind === 'message' ? row.body.blocks.filter(isBackgroundTaskBlock) : [] + ) + expect(tasks.map((task) => [task.taskId, task.state])).toEqual([ + ['killed-task', 'idle'], + ['kept-task', 'working'] + ]) + }) +}) diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts index 1567a871740a..515e62c7cadc 100644 --- a/src/main/acp/acp-tool-timeline.ts +++ b/src/main/acp/acp-tool-timeline.ts @@ -167,13 +167,11 @@ export class AcpToolTimeline { const rows = this.journalItems() const suffix = `${thread ? `${providerTimelineKeyPart(thread)}/` : ''}${providerTimelineKeyPart(`tool:${callId}`)}` const row = rows.find((item) => { + if (item.body.kind !== 'tool-call' || item.body.callId !== callId) { + return false + } const identity = parseAgentJournalItemKey(item.itemId) - return ( - item.body.kind === 'tool-call' && - item.body.callId === callId && - identity?.provider === 'legacy' && - identity.recordId.endsWith(`:${suffix}`) - ) + return identity?.provider === 'legacy' && identity.recordId.endsWith(`:${suffix}`) }) return row?.body.kind === 'tool-call' ? { body: row.body, turn: acpJournalToolTurn(rows, callId) } From 9f144c14d765ee21076daefda56784678f490550 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:46:30 -0700 Subject: [PATCH 081/269] test(native-chat): give the shared-lifecycle host tests the router's agent definitions --- ...tured-agent-session-claude-stop-exit-ends-record.test.ts | 6 +++++- .../structured-agent-session-codex-stop-row.test.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts index cfc2c27ca6be..62bd4ffb5b47 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -20,6 +20,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { claudeAndCodexDefinition } from './structured-agent-session-adapter-router-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { HOST_TEST_NOW as NOW, @@ -84,7 +85,10 @@ beforeEach(async () => { store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, - adapter: Object.assign(adapter, { supportsCreate: () => true }), + adapter: Object.assign(adapter, { + supportsCreate: () => true, + definition: claudeAndCodexDefinition + }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 1dbbac5282d8..dd42f496ba66 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -19,6 +19,7 @@ import { codexTurnLifecycleFake } from '../../codex/codex-turn-lifecycle-fake' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { claudeAndCodexDefinition } from './structured-agent-session-adapter-router-test-support' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -73,7 +74,10 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ logger: (log = recordingStructuredAgentSessionLogger()).logger, store, - adapter: Object.assign(adapter, { supportsCreate: () => true }), + adapter: Object.assign(adapter, { + supportsCreate: () => true, + definition: claudeAndCodexDefinition + }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', From d2c41c7090287ae3adc18a0e7bed5529800e54c9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:05:14 -0700 Subject: [PATCH 082/269] feat(acp): structured chat adapter over ACP with Grok as its first agent One launch-spec row (grok agent stdio, never self-updating, always-approve only with full access), its definition registered beside Claude and Codex, and an adapter that drives D1's protocol runtime, D2's translator and the shared timeline assembler: acquire, dispatch with follow-ups held for the next prompt, Stop, approvals (claim, commit, reply), the agent's own models and commands, close and exit. The child starts without a pane identity so the agent's own Orca status hooks stay silent. --- src/main/acp/acp-launch-specs.ts | 69 ++++ src/main/acp/acp-session-events.ts | 26 ++ src/main/acp/acp-session-runtime.ts | 38 +- src/main/acp/acp-structured-acquire.ts | 250 +++++++++++++ .../acp-structured-adapter.test-support.ts | 212 +++++++++++ .../acp/acp-structured-agent-definitions.ts | 39 +++ src/main/acp/acp-structured-child.ts | 75 ++++ src/main/acp/acp-structured-lane.ts | 145 ++++++++ .../acp/acp-structured-launch-resolution.ts | 104 ++++++ src/main/acp/acp-structured-options.ts | 156 +++++++++ src/main/acp/acp-structured-prompts.ts | 129 +++++++ .../acp-structured-session-adapter-deps.ts | 32 ++ .../acp-structured-session-adapter.test.ts | 330 ++++++++++++++++++ .../acp/acp-structured-session-adapter.ts | 222 ++++++++++++ src/main/acp/acp-structured-session.ts | 90 +++++ src/main/acp/acp-structured-turns.ts | 152 ++++++++ .../codex/codex-structured-owner-identity.ts | 56 +-- .../provider-spawned-process-identity.ts | 69 ++++ .../structured-agent-runtime-registrations.ts | 37 +- .../structured-agent-session-runtime.ts | 7 + .../structured-agent-shell-environment.ts | 5 +- 21 files changed, 2178 insertions(+), 65 deletions(-) create mode 100644 src/main/acp/acp-launch-specs.ts create mode 100644 src/main/acp/acp-session-events.ts create mode 100644 src/main/acp/acp-structured-acquire.ts create mode 100644 src/main/acp/acp-structured-adapter.test-support.ts create mode 100644 src/main/acp/acp-structured-agent-definitions.ts create mode 100644 src/main/acp/acp-structured-child.ts create mode 100644 src/main/acp/acp-structured-lane.ts create mode 100644 src/main/acp/acp-structured-launch-resolution.ts create mode 100644 src/main/acp/acp-structured-options.ts create mode 100644 src/main/acp/acp-structured-prompts.ts create mode 100644 src/main/acp/acp-structured-session-adapter-deps.ts create mode 100644 src/main/acp/acp-structured-session-adapter.test.ts create mode 100644 src/main/acp/acp-structured-session-adapter.ts create mode 100644 src/main/acp/acp-structured-session.ts create mode 100644 src/main/acp/acp-structured-turns.ts create mode 100644 src/main/provider-process/provider-spawned-process-identity.ts diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts new file mode 100644 index 000000000000..764ed89907d3 --- /dev/null +++ b/src/main/acp/acp-launch-specs.ts @@ -0,0 +1,69 @@ +// How Orca starts each ACP agent, as data: adding an agent is one more row (plus a dialect when it +// speaks protocol extensions). Nothing outside `acp-dialects/` branches on an agent's name. + +import { join } from 'node:path' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' + +export type AcpLaunchSpec = { + /** The Orca agent id (a `TuiAgent`), which names the agent's records and its catalog label. */ + agent: string + command: string + /** Built per launch: `fullAccess` is the Agent Permissions setting's bypass posture. */ + args(input: { fullAccess: boolean }): string[] + /** Overlaid on the child's environment. */ + env: Readonly> + dialect: AcpDialect + /** The agent's own sign-in command, for a person to run when it reports auth required. */ + loginCommand: readonly string[] + /** Variable naming the agent's config directory, pinned as each record's account home. */ + accountHomeVariable: string + /** The config directory the agent uses when the variable is unset, under the user's home. */ + defaultAccountHome(homePath: string): string + /** Where the agent installs its own binary, searched after PATH. */ + installDirectories(accountHomePath: string): string[] +} + +const GROK_LAUNCH_SPEC: AcpLaunchSpec = { + agent: 'grok', + command: 'grok', + // Orca owns this child's lifetime: no self-update mid-chat and no shared leader process it + // cannot stop. `--always-approve` only for full access, as the user's setting chooses. + args: ({ fullAccess }) => [ + '--no-auto-update', + 'agent', + '--no-leader', + ...(fullAccess ? ['--always-approve'] : []), + 'stdio' + ], + env: { GROK_DISABLE_AUTOUPDATER: '1' }, + dialect: GROK_ACP_DIALECT, + loginCommand: ['grok', 'login'], + accountHomeVariable: 'GROK_HOME', + defaultAccountHome: (homePath) => join(homePath, '.grok'), + installDirectories: (accountHomePath) => [join(accountHomePath, 'bin')] +} + +export const ACP_LAUNCH_SPECS: readonly AcpLaunchSpec[] = [GROK_LAUNCH_SPEC] + +export function acpLaunchSpecFor(agent: string): AcpLaunchSpec | null { + return ACP_LAUNCH_SPECS.find((spec) => spec.agent === agent) ?? null +} + +/** + * A pane's identity in the inherited environment would let the agent's own Orca status hooks + * report for this session too; the structured session is its one status producer. + */ +export const ACP_CHILD_ENV_TO_DELETE: readonly string[] = [ + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_AGENT_LAUNCH_TOKEN', + 'ORCA_AGENT_PANE', + 'ORCA_AGENT_LAUNCH', + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN', + 'ORCA_AGENT_HOOK_ENDPOINT', + 'ORCA_AGENT_HOOK_ENV', + 'ORCA_AGENT_HOOK_VERSION' +] diff --git a/src/main/acp/acp-session-events.ts b/src/main/acp/acp-session-events.ts new file mode 100644 index 000000000000..47dd54c57a88 --- /dev/null +++ b/src/main/acp/acp-session-events.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' +import { + SessionNotificationSchema, + type SessionNotification +} from './generated/acp-protocol.generated' + +const updateEnvelopeSchema = z.looseObject({ + sessionId: z.string(), + update: z.looseObject({ sessionUpdate: z.string() }) +}) + +export type AcpSessionEvent = + | { kind: 'known'; notification: SessionNotification } + | { kind: 'unrecognized'; sessionId: string; raw: z.infer } + +/** A `session/update`, typed when this build knows its kind; null when it is not one at all. */ +export function readAcpSessionEvent(params: unknown): AcpSessionEvent | null { + const envelope = updateEnvelopeSchema.safeParse(params) + if (!envelope.success) { + return null + } + const parsed = SessionNotificationSchema.safeParse(params) + return parsed.success + ? { kind: 'known', notification: parsed.data } + : { kind: 'unrecognized', sessionId: envelope.data.sessionId, raw: envelope.data } +} diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 77e317362d13..7beddcba79f6 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -1,5 +1,5 @@ import type { Readable, Writable } from 'node:stream' -import { z } from 'zod' +import type { z } from 'zod' import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' import { AcpAuthRequiredError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' import { AcpJsonRpcPeer, type AcpPeerOptions, type AcpRequestContext } from './acp-json-rpc-peer' @@ -16,7 +16,6 @@ import { AuthenticateResponseSchema, PromptResponseSchema, RequestPermissionRequestSchema, - SessionNotificationSchema, SetSessionModeResponseSchema, SetSessionModelResponseSchema, SetSessionConfigOptionResponseSchema, @@ -25,20 +24,14 @@ import { type AuthenticateResponse, type PromptRequest, type PromptResponse, - type SessionNotification, type SetSessionConfigOptionRequest, type SetSessionConfigOptionResponse, type SetSessionModeResponse, type SetSessionModelResponse } from './generated/acp-protocol.generated' -const updateEnvelopeSchema = z.looseObject({ - sessionId: z.string(), - update: z.looseObject({ sessionUpdate: z.string() }) -}) -export type AcpSessionEvent = - | { kind: 'known'; notification: SessionNotification } - | { kind: 'unrecognized'; sessionId: string; raw: z.infer } +import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' +export type { AcpSessionEvent } from './acp-session-events' export type AcpSessionRuntimeOptions = { clientInfo?: InitializeRequest['clientInfo'] @@ -46,6 +39,8 @@ export type AcpSessionRuntimeOptions = { cancelTimeoutMs?: number onPermission?: AcpPermissionHandler onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown + /** Agent notifications other than `session/update`: a dialect's protocol extensions. */ + onExtensionNotification?: (method: string, params: unknown) => void onDiagnostic?: (message: string) => void onClose?: (error: Error) => void } @@ -148,14 +143,18 @@ export class AcpSessionRuntime { return this.starting } - async prompt(prompt: PromptRequest['prompt']): Promise { + async prompt( + prompt: PromptRequest['prompt'], + meta?: PromptRequest['_meta'] + ): Promise { if (this.activePrompt) { throw new Error('ACP prompt already in progress') } const sessionId = this.sessionId() + const params: PromptRequest = { sessionId, prompt, ...(meta ? { _meta: meta } : {}) } const active: ActivePrompt = { cancelling: false, - response: this.call('session/prompt', { sessionId, prompt }, PromptResponseSchema) + response: this.call('session/prompt', params, PromptResponseSchema) } this.activePrompt = active active.response = active.response.finally(() => { @@ -291,18 +290,19 @@ export class AcpSessionRuntime { private handleNotification(method: string, params: unknown): void { if (method !== 'session/update') { + try { + this.options.onExtensionNotification?.(method, params) + } catch (error) { + this.diagnose(`ACP extension listener failed: ${String(error)}`) + } return } - const envelope = updateEnvelopeSchema.safeParse(params) - if (!envelope.success) { + const event = readAcpSessionEvent(params) + if (!event) { this.diagnose('Ignored invalid ACP session update envelope') return } - const parsed = SessionNotificationSchema.safeParse(params) - const event: AcpSessionEvent = parsed.success - ? { kind: 'known', notification: parsed.data } - : { kind: 'unrecognized', sessionId: envelope.data.sessionId, raw: envelope.data } - if (!parsed.success && !this.reportedUpdateAnomaly) { + if (event.kind === 'unrecognized' && !this.reportedUpdateAnomaly) { this.reportedUpdateAnomaly = true this.diagnose('Forwarded unrecognized ACP session update') } diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts new file mode 100644 index 000000000000..26b87439e6ab --- /dev/null +++ b/src/main/acp/acp-structured-acquire.ts @@ -0,0 +1,250 @@ +// Making a reservation real for an ACP agent: spawn the child, record it before any handshake, +// initialize with the client's file system and terminals off, then load the session this chat +// proved or start a new one. + +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' +import { isTuiAgent } from '../../shared/tui-agent-config' +import { + AgentSessionAcquisitionRefusal, + AgentSessionPreSpawnError, + type AgentSessionAcquisition, + type StructuredAgentSessionAcquireInput +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' +import { + providerSpawnedProcessIdentity, + PROVIDER_SPAWN_TOKEN_ENV +} from '../provider-process/provider-spawned-process-identity' +import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' +import { AcpAuthRequiredError, AcpRpcError } from './acp-errors' +import { ACP_CHILD_ENV_TO_DELETE } from './acp-launch-specs' +import { AcpSessionRuntime, type AcpSessionEvent } from './acp-session-runtime' +import type { AcpStructuredChild } from './acp-structured-child' +import { ACP_HANDLE_TRANSPORT } from './acp-structured-agent-definitions' +import { AcpStructuredLane } from './acp-structured-lane' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +import { AcpStructuredOptions } from './acp-structured-options' +import { AcpStructuredPrompts } from './acp-structured-prompts' +import { routeAcpSessionEvent, type AcpStructuredSession } from './acp-structured-session' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' +import { AcpStructuredTurns, type AcpStructuredTurnsDeps } from './acp-structured-turns' +import { RequestPermissionResponseSchema } from './generated/acp-protocol.generated' + +/** ACP's "resource not found": the agent holds no session under the id this chat proved. */ +const ACP_RESOURCE_NOT_FOUND = -32002 +/** Frames an agent may send before its session exists; past this the start is refused. */ +const MAX_EARLY_FRAMES = 2_048 + +export function acpAgentName(agent: string): string { + return isTuiAgent(agent) ? TUI_AGENT_DISPLAY_NAMES[agent] : agent +} + +export async function acquireAcpStructuredSession(input: { + acquire: StructuredAgentSessionAcquireInput + deps: AcpStructuredSessionAdapterDeps + generation: string + /** Registers the child so a close during the acquire can stop it. */ + track: (child: AcpStructuredChild) => void + /** The child's exit, observed while or after the session exists. */ + onExit: (session: AcpStructuredSession | null) => void + onSettled: AcpStructuredTurnsDeps['settle'] + forceClose: (sessionId: string) => void +}): Promise<{ acquisition: AgentSessionAcquisition; session: AcpStructuredSession }> { + const { acquire, deps, generation } = input + const { spec } = deps + const sessionId = acquire.identity.sessionId + const now = deps.now ?? Date.now + const events = acquire.events + const sink = events ? providerTimelineSink(events) : null + if (!sink) { + throw new AgentSessionPreSpawnError(new Error(`${spec.agent} chats need a journal sink`)) + } + const launch: AcpStructuredLaunch = await deps + .resolveLaunch({ identity: acquire.identity }) + .catch((error: unknown) => { + throw new AgentSessionPreSpawnError(error) + }) + const child = deps.spawnChild({ + command: launch.command, + args: launch.args, + cwd: launch.cwd, + env: { + ...structuredSessionChildIdentityEnv(sessionId, launch.env), + [PROVIDER_SPAWN_TOKEN_ENV]: acquire.spawnToken + }, + envToDelete: ACP_CHILD_ENV_TO_DELETE + }) + input.track(child) + let session: AcpStructuredSession | null = null + let lane: AcpStructuredLane | null = null + const options = new AcpStructuredOptions() + const prompts = new AcpStructuredPrompts(() => lane) + const early: (() => void)[] = [] + const whenLane = (deliver: () => void): void => { + if (lane) { + deliver() + } else if (early.length < MAX_EARLY_FRAMES) { + early.push(deliver) + } + } + const runtime = new AcpSessionRuntime(child.stdout, child.stdin, { + clientInfo: { name: 'orca', version: '1' }, + ...(deps.cancelTimeoutMs === undefined ? {} : { cancelTimeoutMs: deps.cancelTimeoutMs }), + onPermission: (request, context) => { + if (launch.fullAccess) { + // Full access: Orca answers yes for the person, as the agent's own bypass flag would. + const allow = request.options.find((option) => option.kind === 'allow_once') + if (allow) { + return { outcome: { outcome: 'selected', optionId: allow.optionId } } + } + } + return prompts + .handle('session/request_permission', request, context) + .then((reply) => RequestPermissionResponseSchema.parse(reply)) + }, + onRequest: (method, params, context) => prompts.handle(method, params, context), + onExtensionNotification: (method, params) => + whenLane(() => lane?.apply(lane.translator.notification(method, params, now()))), + onDiagnostic: (message) => + deps.logger?.warn('ACP agent protocol diagnostic', { + scope: 'acp-diagnostic', + sessionId, + message + }) + }) + runtime.subscribe((event: AcpSessionEvent) => + whenLane(() => lane && routeAcpSessionEvent({ lane, options }, event, now())) + ) + child.onExit(() => { + runtime.close(new Error(child.stderrTail() || `${spec.command} exited`)) + input.onExit(session) + }) + const identity = providerSpawnedProcessIdentity( + acquire, + `${spec.agent} ACP agent`, + deps.readProcessStartTime + ) + const makeLane = (providerSessionId: string): AcpStructuredLane => { + lane = new AcpStructuredLane({ + sink, + sessionId, + agent: spec.agent, + generation, + providerSessionId, + dialect: spec.dialect, + onInputAccepted: (clientMessageId) => session?.turns.accept(clientMessageId), + onFailed: () => input.forceClose(sessionId) + }) + for (const deliver of early.splice(0)) { + deliver() + } + return lane + } + await child.spawned + if (child.pid === undefined) { + throw new AgentSessionPreSpawnError( + new Error(child.stderrTail() || `${launch.command} could not be started`) + ) + } + await identity.onSpawned(child.pid) + try { + const initialized = await runtime.initialize() + const resume = launch.resume + let started: Awaited> | null = null + let liveLane: AcpStructuredLane | null = null + let supersedesKey: string | undefined + if (resume) { + const loading = makeLane(resume.sessionId) + liveLane = loading + const replays = initialized.agentCapabilities?.loadSession === true + if (replays) { + // The translator recognises replayed history against what the journal already holds. + await events?.written?.() + loading.translator.beginLoad() + } + try { + started = await runtime.start({ + cwd: launch.cwd, + mcpServers: [], + sessionId: resume.sessionId + }) + } catch (error) { + const notFound = error instanceof AcpRpcError && error.code === ACP_RESOURCE_NOT_FOUND + if (!notFound || resume.replaceableKey === null) { + throw error + } + // A session this chat created and the agent never saved: a new one takes its place. + supersedesKey = resume.replaceableKey + } finally { + if (replays) { + loading.apply(loading.translator.finishLoad(now())) + } + } + } + if (!started || !liveLane) { + liveLane?.dispose() + lane = null + started = await runtime.start({ cwd: launch.cwd, mcpServers: [] }) + liveLane = makeLane(started.sessionId) + } + options.adoptSession(started.response) + liveLane.apply(liveLane.translator.contextModels(started.response.models, now())) + const process = await identity.read(child.pid) + const link: AgentSessionProviderHandleLink = { + linkId: + deps.mintLinkId?.() ?? `${spec.agent}-${acquire.fence}-${started.sessionId}`.slice(0, 128), + handle: { transport: ACP_HANDLE_TRANSPORT, agent: spec.agent, nativeId: started.sessionId }, + origin: started.kind === 'new' ? 'created' : 'resumed', + mintedAtFence: acquire.fence, + observedAt: now(), + ...(supersedesKey ? { supersedesKey } : {}) + } + session = { + sessionId, + fence: acquire.fence, + acquisitionGeneration: generation, + spec, + child, + runtime, + lane: liveLane, + prompts, + options, + turns: new AcpStructuredTurns({ + runtime, + lane: liveLane, + agentName: acpAgentName(spec.agent), + now, + settle: input.onSettled + }), + closeRequested: false, + ended: false, + exitObservedAt: null + } + const reading = liveLane + const unbind = events?.bindReadingControl?.({ + pauseReading: () => child.stdout.pause(), + resumeReading: () => { + child.stdout.resume() + reading.retry() + } + }) + if (unbind) { + session.unbindReadingControl = unbind + } + if (child.exited) { + throw new Error(child.stderrTail() || `${spec.command} exited while starting`) + } + return { acquisition: { process, link, acquisitionGeneration: generation }, session } + } catch (error) { + session = null + lane?.dispose() + if (error instanceof AcpAuthRequiredError) { + throw new AgentSessionAcquisitionRefusal( + `${spec.agent} reported that it is not signed in: ${error.message}`, + 'notSignedIn' + ) + } + throw error + } +} diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts new file mode 100644 index 000000000000..5a0ffcf07491 --- /dev/null +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -0,0 +1,212 @@ +// A scripted ACP agent behind the real adapter: a fake child over in-memory stdio, the real +// protocol runtime, translator and assembler, and a real on-disk journal to read back. + +import { vi } from 'vitest' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + openProviderTimelineRig, + SESSION, + type ProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { acpLaunchSpecFor } from './acp-launch-specs' +import { AcpScriptedAgent, tick, type FakeFrame } from './acp-scripted-agent.test-support' +import type { AcpStructuredChild } from './acp-structured-child' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +import { AcpStructuredSessionAdapter } from './acp-structured-session-adapter' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' + +export const GROK = acpLaunchSpecFor('grok')! +export const PROVIDER_SESSION = 'acp-session-1' +export const PID = 4242 + +export const GROK_CONFIG_OPTIONS = [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'grok-4.7', + options: [ + { value: 'grok-4.7', name: 'Grok 4.7' }, + { value: 'grok-4.6', name: 'Grok 4.6' } + ] + }, + { + id: 'reasoning_effort', + name: 'Reasoning Effort', + category: 'thought_level', + type: 'select', + currentValue: 'high', + options: [ + { value: 'high', name: 'High' }, + { value: 'low', name: 'Low' } + ] + } +] + +export class FakeAcpChild implements AcpStructuredChild { + readonly agent = new AcpScriptedAgent() + readonly pid: number | undefined = PID + readonly spawned = Promise.resolve() + stderr = '' + private listeners: (() => void)[] = [] + private gone = false + closes = 0 + + constructor(readonly launch: ProviderProcessLaunch) {} + + get stdout() { + return this.agent.stdout + } + get stdin() { + return this.agent.stdin + } + get exited() { + return this.gone + } + onExit(listener: () => void): void { + if (this.gone) { + listener() + } else { + this.listeners.push(listener) + } + } + stderrTail(): string { + return this.stderr + } + async close(): Promise { + this.closes += 1 + this.exit() + return true + } + /** The agent process ends on its own. */ + exit(): void { + if (this.gone) { + return + } + this.gone = true + for (const listener of this.listeners.splice(0)) { + listener() + } + } +} + +export type AcpAdapterRig = { + rig: ProviderTimelineRig + adapter: AcpStructuredSessionAdapter + child: () => FakeAcpChild + spawned: string[] + lifecycle: StructuredAgentSessionLifecycleEvent[] + settled: Parameters>[0][] + acquire(options?: { fence?: number; onSpawned?: () => Promise }): ReturnType< + AcpStructuredSessionAdapter['acquire'] + > + /** Frames Orca wrote to the agent with this method. */ + sent(method: string): FakeFrame[] + /** Waits for the `index`th frame Orca writes with this method. */ + frame(method: string, index?: number): Promise + settle(): Promise +} + +export async function openAcpAdapterRig( + options: { + launch?: Partial + initialize?: Record + script?: (agent: AcpScriptedAgent) => void + } = {} +): Promise { + const rig = await openProviderTimelineRig() + let current: FakeAcpChild | null = null + const spawned: string[] = [] + const lifecycle: StructuredAgentSessionLifecycleEvent[] = [] + const settled: AcpAdapterRig['settled'] = [] + const adapter = new AcpStructuredSessionAdapter({ + spec: GROK, + resolveLaunch: async () => ({ + spec: GROK, + command: '/opt/grok/bin/grok', + args: GROK.args({ fullAccess: false }), + cwd: '/workspace/project', + env: { PATH: '/usr/bin', ORCA_PANE_KEY: 'tab-1:pane-1', ORCA_AGENT_HOOK_PORT: '1234' }, + fullAccess: false, + resume: null, + ...options.launch + }), + spawnChild: (launch) => { + spawned.push('spawn') + const child = new FakeAcpChild(launch) + current = child + const { agent } = child + agent.on('initialize', (frame) => { + spawned.push('initialize') + agent.reply(frame, { + protocolVersion: 1, + agentCapabilities: { loadSession: true }, + ...options.initialize + }) + }) + const opened = { sessionId: PROVIDER_SESSION, configOptions: GROK_CONFIG_OPTIONS } + agent.on('session/new', (frame) => agent.reply(frame, opened)) + agent.on('session/load', (frame) => agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS })) + options.script?.(agent) + return child + }, + readProcessStartTime: async () => 1_700_000_000_000, + onEvent: (event) => lifecycle.push(event), + onDispatchSettledLate: (settlement) => settled.push(settlement), + mintGeneration: () => 'gen-acp', + now: () => 5_000, + cancelTimeoutMs: 1_000 + }) + const frames = () => current?.agent.frames ?? [] + return { + rig, + adapter, + child: () => { + if (!current) { + throw new Error('no ACP child spawned') + } + return current + }, + spawned, + lifecycle, + settled, + acquire: (acquireOptions = {}) => + adapter.acquire({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: null + }, + fence: acquireOptions.fence ?? 1, + spawnToken: 'spawn-1', + events: rig.eventSink, + onSpawned: async () => { + spawned.push('onSpawned') + await acquireOptions.onSpawned?.() + } + }), + sent: (method) => frames().filter((frame) => frame.method === method), + frame: (method, index = 0) => + waitFor(() => { + const frame = frames().filter((entry) => entry.method === method)[index] + if (!frame) { + throw new Error(`no ${method} frame #${index} yet`) + } + return frame + }), + settle: async () => { + for (let round = 0; round < 5; round += 1) { + await tick() + } + await rig.rows() + } + } +} + +export function waitFor(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { timeout: 2_000, interval: 5 }) +} diff --git a/src/main/acp/acp-structured-agent-definitions.ts b/src/main/acp/acp-structured-agent-definitions.ts new file mode 100644 index 000000000000..1f0e8db4a5e3 --- /dev/null +++ b/src/main/acp/acp-structured-agent-definitions.ts @@ -0,0 +1,39 @@ +import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import type { AcpLaunchSpec } from './acp-launch-specs' + +/** Every ACP agent's handles live in one protocol's id space; the agent part keeps them apart. */ +export const ACP_HANDLE_TRANSPORT = 'acp' + +/** Session options an ACP agent takes: its model, and the model's reasoning effort. */ +const ACP_OPTION_KEYS: ReadonlySet = new Set(['model', 'effort']) + +export function isAcpStructuredOptionKey(key: string): boolean { + return ACP_OPTION_KEYS.has(key) +} + +/** What the host knows about an ACP agent before any of its sessions runs. */ +export function acpStructuredAgentDefinition(spec: AcpLaunchSpec): StructuredAgentDefinition { + return { + agent: spec.agent, + handleTransport: ACP_HANDLE_TRANSPORT, + accountHomeVariable: spec.accountHomeVariable, + capabilities: { + // ACP has no stable rewind, compact or goal method; a command arrives through `/` instead. + rewind: false, + compact: false, + threadGoal: false, + contextUsage: true, + // Declared off until the agent's own initialize says otherwise; dispatch re-checks it. + imagePrompts: false, + steering: 'queue', + // Orca answers every permission request the agent sends; the agent decides when it asks. + approvalEnforcement: 'orca' + }, + restingOptions: { + acceptsKey: isAcpStructuredOptionKey, + // The agent lists its models over the protocol once it runs; Orca keeps no list of its own. + fallbackModels: () => null, + effortDefaultsToModel: false + } + } +} diff --git a/src/main/acp/acp-structured-child.ts b/src/main/acp/acp-structured-child.ts new file mode 100644 index 000000000000..9577f7e8f838 --- /dev/null +++ b/src/main/acp/acp-structured-child.ts @@ -0,0 +1,75 @@ +// The ACP agent's process, as the adapter needs it: its stdio for the protocol, its observed exit, +// and a close that answers true only once that exit is proven. Supervision, the process-tree +// teardown and Windows-safe spawning are the shared provider lifecycle's. + +import type { Readable, Writable } from 'node:stream' +import { spawnProcess } from '../../shared/child-process/run-process' +import { spawnManagedProviderProcess } from '../provider-process/managed-provider-process' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' + +const GRACEFUL_EXIT_MS = 1_500 +const FORCED_EXIT_MS = 1_000 +const STDERR_TAIL_MAX_CHARS = 8_192 + +export type AcpStructuredChild = { + readonly pid: number | undefined + /** Agent → Orca. */ + readonly stdout: Readable + /** Orca → agent. */ + readonly stdin: Writable + readonly exited: boolean + /** Resolves once the spawn either produced a process or failed to. */ + readonly spawned: Promise + onExit(listener: () => void): void + /** The agent's own last words, for a failure a person reads. */ + stderrTail(): string + /** True only once the exit is proven. */ + close(): Promise +} + +export type SpawnAcpStructuredChild = (launch: ProviderProcessLaunch) => AcpStructuredChild + +export function spawnAcpStructuredChild( + launch: ProviderProcessLaunch, + spawnImpl: typeof spawnProcess = spawnProcess +): AcpStructuredChild { + const managed = spawnManagedProviderProcess(launch, { + spawnImpl, + site: 'acp-agent-teardown', + closeEventIsExit: true, + policy: (supervised) => ({ + gracefulExitMs: supervised ? PROVIDER_SUPERVISOR_MAX_STOP_MS : GRACEFUL_EXIT_MS, + forcedExitMs: FORCED_EXIT_MS + }) + }) + const { child } = managed + let stderr = '' + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderr = (stderr + chunk).slice(-STDERR_TAIL_MAX_CHARS) + }) + // A broken pipe surfaces as the exit the managed process observes; never as an uncaught error. + child.stdin.on('error', () => {}) + const spawned = new Promise((resolve) => { + if (child.pid !== undefined) { + resolve() + return + } + child.once('spawn', () => resolve()) + child.once('error', () => resolve()) + }) + return { + get pid() { + return child.pid + }, + stdout: child.stdout, + stdin: child.stdin, + get exited() { + return managed.rootVerdict === 'exited' + }, + spawned, + onExit: (listener) => managed.onExit(() => listener()), + stderrTail: () => stderr.trim(), + close: async () => managed.rootVerdict === 'exited' || (await managed.close()) === 'exited' + } +} diff --git a/src/main/acp/acp-structured-lane.ts b/src/main/acp/acp-structured-lane.ts new file mode 100644 index 000000000000..4b0a43079fdc --- /dev/null +++ b/src/main/acp/acp-structured-lane.ts @@ -0,0 +1,145 @@ +// One ACP child's path into the journal: the translator turns its traffic into grammar events, and +// the shared assembler writes them. Events apply strictly in order; one the sink refuses under +// backpressure holds every later one until the sink drains, so nothing is reordered or dropped. + +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionTransitionJournal } from '../native-chat/agent-session-wire/structured-agent-session-transition' +import { + createProviderTimelineAssembler, + type ProviderTimelineAssembler +} from '../native-chat/agent-session-timeline/provider-timeline-assembler' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { spellProviderTimelineKey } from '../native-chat/agent-session-timeline/provider-timeline-identity' +import type { ProviderTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import { AcpTimelineTranslator } from './acp-timeline-translator' + +/** How long a held event waits for the sink to say it drained before trying again on its own. */ +const BACKPRESSURE_RETRY_MS = 250 + +/** The committed rows in journal order, which the translator reads to recognise replayed work. */ +function journalRenderItems( + journal: StructuredAgentSessionTransitionJournal | null +): AgentJournalRenderItem[] { + if (!journal) { + return [] + } + const order: { itemId: string; sequence: number }[] = [] + journal.visitItems((itemId, sequence) => order.push({ itemId, sequence })) + order.sort((left, right) => left.sequence - right.sequence) + return order.flatMap(({ itemId }) => journal.item(itemId) ?? []) +} + +export type AcpStructuredLaneDeps = { + sink: ProviderTimelineSink + sessionId: string + agent: string + generation: string + providerSessionId: string + dialect: AcpDialect + /** A send of Orca's reached the agent: the turn it opened has its first provider event. */ + onInputAccepted: (clientMessageId: string) => void + /** The sink refused for good; nothing more this child says can be journaled. */ + onFailed: (reason: string) => void +} + +export class AcpStructuredLane { + readonly translator: AcpTimelineTranslator + private readonly assembler: ProviderTimelineAssembler + private readonly backlog: ProviderTimelineEvent[] = [] + private retryTimer: ReturnType | null = null + private failed = false + private disposed = false + + constructor(private readonly deps: AcpStructuredLaneDeps) { + this.translator = new AcpTimelineTranslator({ + sessionId: deps.providerSessionId, + journalItems: () => journalRenderItems(deps.sink.journalItems()), + dialect: deps.dialect + }) + this.assembler = createProviderTimelineAssembler({ + sink: deps.sink, + sessionId: deps.sessionId, + agent: deps.agent, + generation: deps.generation, + namespace: deps.providerSessionId + }) + } + + get openTurnId(): string | null { + return this.assembler.openTurnId + } + + apply(events: readonly ProviderTimelineEvent[]): void { + if (this.failed || this.disposed) { + return + } + this.backlog.push(...events) + this.drain() + } + + /** The sink drained: whatever it held back goes now. */ + retry(): void { + this.drain() + } + + /** The legacy-scheme record a request row of `requestKey` is written under, before any + * incarnation suffix a reused key adds. */ + requestRecordPrefix(requestKey: string): string { + return `request:${spellProviderTimelineKey(this.deps.providerSessionId, { + source: 'provider', + value: requestKey + })}` + } + + /** Whether the journal row `itemId` is the row of request `requestKey`. */ + isRequestRow(itemId: string, requestKey: string): boolean { + const identity = parseAgentJournalItemKey(itemId) + if (identity?.provider !== 'legacy' || identity.sessionId !== this.deps.sessionId) { + return false + } + const prefix = this.requestRecordPrefix(requestKey) + return identity.recordId === prefix || identity.recordId.startsWith(`${prefix}#`) + } + + flush(): void { + this.assembler.flush() + } + + dispose(): void { + this.disposed = true + this.clearRetry() + this.backlog.length = 0 + this.assembler.dispose() + } + + private drain(): void { + this.clearRetry() + while (this.backlog.length > 0 && !this.failed && !this.disposed) { + const event = this.backlog[0] + const { admission } = this.assembler.apply(event) + if (!admission.accepted) { + if (admission.reason === 'backpressure') { + this.retryTimer = setTimeout(() => this.drain(), BACKPRESSURE_RETRY_MS) + return + } + this.failed = true + this.backlog.length = 0 + this.deps.onFailed(admission.reason) + return + } + this.backlog.shift() + if (event.type === 'input.accepted') { + this.deps.onInputAccepted(event.clientMessageId) + } + } + } + + private clearRetry(): void { + if (this.retryTimer) { + clearTimeout(this.retryTimer) + this.retryTimer = null + } + } +} diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts new file mode 100644 index 000000000000..870ba68803ef --- /dev/null +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -0,0 +1,104 @@ +// How a durable session record becomes an ACP agent launch, on the machine this runtime runs on. +// +// Every input is read back from the record the store made durable, never from the call that +// triggered the acquire: the working directory, the account home, and the provider session a +// resume names are the ones this session proved. + +import { delimiter } from 'node:path' +import { homedir } from 'node:os' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { + agentSessionProviderHandleChainHead, + agentSessionProviderHandleKey +} from '../../shared/agent-session-provider-handle' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import type { AcpLaunchSpec } from './acp-launch-specs' + +export type AcpStructuredLaunch = { + spec: AcpLaunchSpec + command: string + args: string[] + cwd: string + env: Record + fullAccess: boolean + /** The provider session to load; null starts a new one. */ + resume: { + sessionId: string + /** Only a session this chat created may be one the agent never saved, and so be replaced. */ + replaceableKey: string | null + } | null +} + +export type AcpStructuredLaunchResolverDeps = { + store: Pick + resolveWorkspacePath: (workspaceId: string) => Promise + /** The shared base env; re-read per acquisition. */ + resolveEnvironment: () => Promise> + /** The user's per-agent environment overlay from settings. */ + resolveLaunchEnv?: (agent: string) => Record + /** The Agent Permissions setting's bypass posture for this agent, re-read per acquisition. */ + resolveFullAccess?: (agent: string) => boolean + resolveCommand?: typeof resolveCliCommand + homePath?: string +} + +export function createAcpStructuredLaunchResolver( + spec: AcpLaunchSpec, + deps: AcpStructuredLaunchResolverDeps +): (input: { identity: AgentSessionJournalIdentity }) => Promise { + return async ({ identity }) => { + const record = deps.store.getRecord(identity.sessionId) + if (!record) { + throw new Error(`no durable agent-session record for ${identity.sessionId}`) + } + if (record.provider !== spec.agent) { + throw new Error(`session ${identity.sessionId} is a ${record.provider} session`) + } + const { location, accountHome } = record + // A session pinned elsewhere belongs to that host's runtime; starting it here would put a + // second writer on the same provider session. + if (location.executionHostId !== LOCAL_EXECUTION_HOST_ID || location.wslDistro !== null) { + throw new Error( + `${spec.agent} structured sessions run on this runtime's host, not ${location.executionHostId}` + ) + } + if (accountHome.variable !== spec.accountHomeVariable) { + throw new Error( + `${spec.agent} sessions pin ${spec.accountHomeVariable}, not ${accountHome.variable}` + ) + } + const base = await deps.resolveEnvironment() + const env: Record = { + ...base, + ...deps.resolveLaunchEnv?.(spec.agent), + ...spec.env, + [spec.accountHomeVariable]: accountHome.path + } + const pathEnv = [env.PATH ?? env.Path, ...spec.installDirectories(accountHome.path)] + .filter((entry): entry is string => Boolean(entry)) + .join(delimiter) + const command = (deps.resolveCommand ?? resolveCliCommand)(spec.command, { + pathEnv, + homePath: env.HOME ?? env.USERPROFILE ?? deps.homePath ?? homedir() + }) + const fullAccess = deps.resolveFullAccess?.(spec.agent) ?? false + const head = agentSessionProviderHandleChainHead(record.providerHandleChain) + return { + spec, + command, + args: spec.args({ fullAccess }), + cwd: await deps.resolveWorkspacePath(location.workspaceId), + env, + fullAccess, + resume: head + ? { + sessionId: head.handle.nativeId, + replaceableKey: + head.origin === 'created' ? agentSessionProviderHandleKey(head.handle) : null + } + : null + } + } +} diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts new file mode 100644 index 000000000000..49721f3e2276 --- /dev/null +++ b/src/main/acp/acp-structured-options.ts @@ -0,0 +1,156 @@ +// What a running ACP agent reports about its own choices — config options (model, reasoning +// effort), the older model list, and its `/` commands — as Orca's option and command surfaces. +// Kept as the agent last reported it; nothing here is guessed or listed by Orca itself. + +import type { + AgentSessionModelOption, + AgentSessionOptionChoice, + AgentSessionOptionsResult, + AgentSessionSlashCommand +} from '../../shared/agent-session-wire' +import { + SessionConfigSelectGroupSchema, + SessionConfigSelectOptionSchema, + type AvailableCommand, + type SessionConfigOption, + type SessionConfigSelectOption, + type SessionModelState +} from './generated/acp-protocol.generated' + +type SelectOption = Extract + +/** How an Orca option key reaches the agent: a config option it declared, or the model method. */ +export type AcpOptionWrite = + | { method: 'config'; configId: string; value: string } + | { method: 'model'; modelId: string } + +function selectChoices(option: SelectOption): SessionConfigSelectOption[] { + const choices: SessionConfigSelectOption[] = [] + for (const entry of option.options) { + const group = SessionConfigSelectGroupSchema.safeParse(entry) + if (group.success) { + choices.push(...group.data.options) + continue + } + const choice = SessionConfigSelectOptionSchema.safeParse(entry) + if (choice.success) { + choices.push(choice.data) + } + } + return choices +} + +function isSelect(option: SessionConfigOption): option is SelectOption { + return option.type === 'select' +} + +export class AcpStructuredOptions { + private configOptions: SessionConfigOption[] = [] + private models: SessionModelState | null = null + private commands: AgentSessionSlashCommand[] | undefined + + /** The session state a new, load or resume response reported. */ + adoptSession(response: { + configOptions?: SessionConfigOption[] | null + models?: SessionModelState | null + }): void { + this.configOptions = response.configOptions ?? [] + this.models = response.models ?? null + } + + adoptConfigOptions(configOptions: SessionConfigOption[] | null | undefined): void { + if (configOptions) { + this.configOptions = configOptions + } + } + + adoptModel(modelId: string): void { + if (this.models) { + this.models = { ...this.models, currentModelId: modelId } + } + } + + adoptCommands(commands: readonly AvailableCommand[]): void { + this.commands = commands.map((command) => ({ + name: command.name, + kind: 'command', + ...(command.description ? { description: command.description } : {}), + ...(command.input?.hint ? { argumentHint: command.input.hint } : {}) + })) + } + + readCommands(): AgentSessionSlashCommand[] | undefined { + return this.commands + } + + /** Where a pick of `key` goes; null when the agent offers nothing for it. */ + write(key: string, value: string): AcpOptionWrite | null { + if (key === 'model') { + const option = this.select('model') + if (option) { + return { method: 'config', configId: option.id, value } + } + return this.models ? { method: 'model', modelId: value } : null + } + if (key === 'effort') { + const option = this.select('thought_level') + return option ? { method: 'config', configId: option.id, value } : null + } + return null + } + + read(): Pick { + const modelOption = this.select('model') + const effortOption = this.select('thought_level') + const efforts: AgentSessionOptionChoice[] = effortOption + ? selectChoices(effortOption).map((choice) => ({ + value: choice.value, + label: choice.name, + ...(choice.description ? { description: choice.description } : {}) + })) + : [] + const currentModel = modelOption?.currentValue ?? this.models?.currentModelId ?? '' + const listed: { id: string; label: string; description?: string }[] = modelOption + ? selectChoices(modelOption).map((choice) => ({ + id: choice.value, + label: choice.name, + ...(choice.description ? { description: choice.description } : {}) + })) + : (this.models?.availableModels.map((model) => ({ + id: model.modelId, + label: model.name, + ...(model.description ? { description: model.description } : {}) + })) ?? []) + const models: AgentSessionModelOption[] = listed.map((model) => ({ + ...model, + isDefault: model.id === currentModel, + efforts, + ...(effortOption ? { defaultEffort: effortOption.currentValue } : {}) + })) + const confirmed = [...(currentModel ? ['model'] : []), ...(effortOption ? ['effort'] : [])] + return { + models, + current: { + model: currentModel, + ...(effortOption ? { effort: effortOption.currentValue } : {}), + confirmed + } + } + } + + /** The values the agent reports now, as the record's options would name them. */ + reported(): Readonly> { + const { current } = this.read() + return { + ...(current.model ? { model: current.model } : {}), + ...(current.effort ? { effort: current.effort } : {}) + } + } + + private select(category: string): SelectOption | null { + const option = this.configOptions.find( + (entry) => entry.category === category || (category === 'model' && entry.id === 'model') + ) + return option && isSelect(option) ? option : null + } +} diff --git a/src/main/acp/acp-structured-prompts.ts b/src/main/acp/acp-structured-prompts.ts new file mode 100644 index 000000000000..5b97aab1ffe8 --- /dev/null +++ b/src/main/acp/acp-structured-prompts.ts @@ -0,0 +1,129 @@ +// The agent's open requests to the user (permission, question, plan approval), each held as the +// JSON-RPC call it is until a person answers it, a Stop cancels it, or the agent withdraws it. +// An answer claims the request, commits the journal compare-and-set while the claim is held, and +// only then replies, so a second client loses the commit and the agent hears exactly one answer. + +import type { AgentSessionPromptResponse } from '../../shared/agent-session-question-answer' +import { + AgentSessionPromptAnswerRejectedError, + AgentSessionPromptUnavailableError +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { AcpRequestPresentation } from './acp-dialects/acp-dialect' +import { AcpRpcError } from './acp-errors' +import type { AcpRequestContext } from './acp-json-rpc-peer' +import type { AcpStructuredLane } from './acp-structured-lane' + +type OpenRequest = { + key: string + presentation: AcpRequestPresentation + claimed: boolean + settle: (reply: unknown) => void +} + +export class AcpStructuredPrompts { + private readonly open = new Map() + + constructor(private readonly lane: () => AcpStructuredLane | null) {} + + get size(): number { + return this.open.size + } + + /** Answers one agent request: a row the user can act on, or the translator's verdict on it. */ + handle(method: string, params: unknown, context: AcpRequestContext): Promise { + const lane = this.lane() + if (!lane || context.id === null) { + throw new AcpRpcError(-32603, 'ACP request arrived with no session to show it') + } + const translated = lane.translator.request(method, params, context.id) + lane.apply(translated.events) + const opened = translated.events.find((event) => event.type === 'request.open') + if (!translated.presentation || opened?.type !== 'request.open') { + throw new AcpRpcError(-32601, `Unsupported ACP client method: ${method}`) + } + const key = opened.request + const presentation = translated.presentation + return new Promise((resolve) => { + const entry: OpenRequest = { + key, + presentation, + claimed: false, + settle: (reply) => { + if (this.open.get(key) === entry) { + this.open.delete(key) + } + resolve(reply) + } + } + this.open.set(key, entry) + // The agent or a Stop gave up on it: its card can no longer be answered. + context.signal.addEventListener( + 'abort', + () => { + if (this.open.get(key) === entry && !entry.claimed) { + this.open.delete(key) + this.lane()?.apply([{ type: 'request.withdrawn', request: key }]) + } + }, + { once: true } + ) + }) + } + + async answer(input: { + itemId: string + response: AgentSessionPromptResponse + commit: () => Promise + }): Promise { + const entry = this.find(input.itemId) + if (!entry) { + throw new AgentSessionPromptUnavailableError(input.itemId) + } + let reply: unknown + try { + reply = entry.presentation.reply(input.response) + } catch (error) { + throw new AgentSessionPromptAnswerRejectedError( + error instanceof Error ? error.message : String(error) + ) + } + entry.claimed = true + try { + await input.commit() + } catch (error) { + entry.claimed = false + throw error + } + entry.settle(reply) + } + + /** Declines every unclaimed request the way the agent's protocol spells a cancellation, and + * closes its card. */ + cancelAll(): void { + for (const entry of Array.from(this.open.values())) { + if (entry.claimed) { + continue + } + entry.settle(entry.presentation.reply(null)) + this.lane()?.apply([{ type: 'request.withdrawn', request: entry.key }]) + } + } + + /** The child is gone: nobody can answer for it any more. */ + clear(): void { + this.open.clear() + } + + private find(itemId: string): OpenRequest | null { + const lane = this.lane() + if (!lane) { + return null + } + for (const entry of this.open.values()) { + if (!entry.claimed && lane.isRequestRow(itemId, entry.key)) { + return entry + } + } + return null + } +} diff --git a/src/main/acp/acp-structured-session-adapter-deps.ts b/src/main/acp/acp-structured-session-adapter-deps.ts new file mode 100644 index 000000000000..416bfbfde813 --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-deps.ts @@ -0,0 +1,32 @@ +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' +import type { AcpLaunchSpec } from './acp-launch-specs' +import type { SpawnAcpStructuredChild } from './acp-structured-child' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' + +export type AcpStructuredSessionAdapterDeps = { + spec: AcpLaunchSpec + resolveLaunch: (input: { identity: AgentSessionJournalIdentity }) => Promise + spawnChild: SpawnAcpStructuredChild + readProcessStartTime?: (pid: number) => Promise + /** Every exit, expected or not: the host ends that child's record. */ + onEvent?: (event: StructuredAgentSessionLifecycleEvent) => void + /** A send this adapter admitted, settled once the agent answered for it. */ + onDispatchSettledLate?: ( + settlement: { sessionId: string; clientMessageId: string } & ( + | { providerIdentity: AgentJournalItemIdentity } + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason: string } + ) + ) => void + logger?: StructuredAgentSessionLogger + now?: () => number + mintGeneration?: () => string + mintLinkId?: () => string + /** Bounds a Stop's wait for the agent to end its turn; past it the child is closed. */ + cancelTimeoutMs?: number + isWindowsProcessStartTimeAvailable?: () => boolean +} diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts new file mode 100644 index 000000000000..b10cf9f25d8e --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -0,0 +1,330 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { ACP_CHILD_ENV_TO_DELETE } from './acp-launch-specs' +import { + GROK, + openAcpAdapterRig, + PID, + PROVIDER_SESSION, + waitFor, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' +import { SESSION } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const hello: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }] +} + +function send(rig: AcpAdapterRig, clientMessageId: string) { + return rig.adapter.dispatch({ sessionId: SESSION, clientMessageId, body: hello, fence: 1 }) +} + +function chunk(promptId: string, text: string) { + return { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } }, + _meta: { promptId } + } +} + +describe('ACP structured session adapter: acquire', () => { + it('records the child before the handshake and starts a new session with client fs and terminals off', async () => { + const rig = await openAcpAdapterRig() + const acquired = await rig.acquire() + expect(rig.spawned).toEqual(['spawn', 'onSpawned', 'initialize']) + expect(acquired.process).toMatchObject({ pid: PID, spawnToken: 'spawn-1', hostId: 'local' }) + expect(acquired.link).toMatchObject({ + handle: { transport: 'acp', agent: 'grok', nativeId: PROVIDER_SESSION }, + origin: 'created', + mintedAtFence: 1 + }) + expect(rig.sent('initialize')[0]?.params).toMatchObject({ + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false } + }) + expect(rig.sent('session/new')[0]?.params).toEqual({ + cwd: '/workspace/project', + mcpServers: [] + }) + }) + + it('launches without any pane identity, so the agent own status hooks stay silent', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const launch = rig.child().launch + expect(launch.envToDelete).toEqual(expect.arrayContaining([...ACP_CHILD_ENV_TO_DELETE])) + expect(launch.envToDelete).toEqual( + expect.arrayContaining(['ORCA_PANE_KEY', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN']) + ) + expect(launch.env).toMatchObject({ + ORCA_AGENT_SESSION_ID: SESSION, + ORCA_AGENT_SESSION_SPAWN_TOKEN: 'spawn-1' + }) + }) + + it('loads the session this chat proved, replaying nothing over a journal that holds it', async () => { + const rig = await openAcpAdapterRig({ + launch: { resume: { sessionId: PROVIDER_SESSION, replaceableKey: null } } + }) + const acquired = await rig.acquire({ fence: 2 }) + expect(rig.sent('session/load')[0]?.params).toMatchObject({ sessionId: PROVIDER_SESSION }) + expect(rig.sent('session/new')).toHaveLength(0) + expect(acquired.link).toMatchObject({ origin: 'resumed', mintedAtFence: 2 }) + }) + + it('replaces a created session the agent never saved with a new one', async () => { + const rig = await openAcpAdapterRig({ + launch: { resume: { sessionId: 'lost', replaceableKey: 'old-key' } }, + script: (agent) => + agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Session not found')) + }) + const acquired = await rig.acquire({ fence: 2 }) + expect(acquired.link).toMatchObject({ origin: 'created', supersedesKey: 'old-key' }) + }) + + it('refuses a signed-out agent through the existing start-failure surface and stops its child', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required')) + }) + const failure = await rig.acquire().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AgentSessionAcquisitionRefusal) + expect(failure).toMatchObject({ reason: 'notSignedIn' }) + expect(rig.child().closes).toBe(1) + }) +}) + +describe('ACP structured session adapter: turns', () => { + it('sends a prompt under an injected id and settles it on the agent first event', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + expect(await send(rig, 'send-1')).toEqual({ state: 'admitted' }) + const prompt = await rig.frame('session/prompt') + expect(prompt.params).toMatchObject({ + prompt: [{ type: 'text', text: 'hello' }], + _meta: { promptId: 'prompt:send-1', requestId: 'prompt:send-1' } + }) + rig.child().agent.notify('session/update', chunk('prompt:send-1', 'Hi there')) + await waitFor(() => + expect(rig.settled).toEqual([ + { + sessionId: SESSION, + clientMessageId: 'send-1', + providerIdentity: { provider: 'orca', clientMessageId: 'send-1' } + } + ]) + ) + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + await waitFor(async () => { + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.at(-1)).toMatchObject({ state: 'completed', outcome: 'success' }) + }) + }) + + it('holds a follow-up until the running prompt is answered, then sends it as the next prompt', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + const first = await rig.frame('session/prompt') + await send(rig, 'send-2') + expect(rig.adapter.holdsDispatch(SESSION)).toBe(true) + await rig.settle() + expect(rig.sent('session/prompt')).toHaveLength(1) + rig.child().agent.reply(first, { stopReason: 'end_turn' }) + const second = await rig.frame('session/prompt', 1) + expect(second.params).toMatchObject({ _meta: { promptId: 'prompt:send-2' } }) + }) + + it('stops a running turn: open permissions answer cancelled, session/cancel goes out', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + const prompt = await rig.frame('session/prompt') + const { agent } = rig.child() + const permission = agent.request(7, 'session/request_permission', { + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId: 'call-1', title: 'Write file' }, + options: [{ optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }] + }) + agent.on('session/cancel', () => agent.reply(prompt, { stopReason: 'cancelled' })) + await rig.settle() + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + expect(await permission).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect(rig.sent('session/cancel')).toHaveLength(1) + await waitFor(async () => { + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.at(-1)).toMatchObject({ state: 'interrupted' }) + }) + }) + + it('answers a Stop with no turn running as one the agent was not running', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: false, + refusal: { turnNotRunning: true } + }) + }) +}) + +describe('ACP structured session adapter: approvals', () => { + it('commits the journal answer before replying with the option the person chose', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + await rig.frame('session/prompt') + const { agent } = rig.child() + const reply = agent.request(3, 'session/request_permission', { + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId: 'call-1', title: 'Write file' }, + options: [ + { optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }, + { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' } + ] + }) + const row = await waitFor(async () => { + const found = (await rig.rig.rows()).find((item) => item.body.kind === 'approval') + expect(found).toBeDefined() + return found! + }) + const order: string[] = [] + let answered = false + void reply.then(() => { + answered = true + order.push('reply') + }) + await rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: row.itemId, + kind: 'approval', + response: { kind: 'option', optionId: 'reject-once' }, + fence: 1, + commit: async () => { + order.push('commit') + expect(answered).toBe(false) + } + }) + expect(await reply).toMatchObject({ + result: { outcome: { outcome: 'selected', optionId: 'reject-once' } } + }) + expect(order).toEqual(['commit', 'reply']) + await expect( + rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: row.itemId, + kind: 'approval', + response: { kind: 'option', optionId: 'allow-once' }, + fence: 1, + commit: async () => {} + }) + ).rejects.toThrow(/no longer waiting/) + }) +}) + +describe('ACP structured session adapter: options and commands', () => { + it('reads the agent own models and effort and writes a pick through set_config_option', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/set_config_option', (frame) => agent.reply(frame, { configOptions: [] })) + }) + await rig.acquire() + const options = await rig.adapter.readOptions({ sessionId: SESSION, fence: 1 }) + expect(options.current).toMatchObject({ model: 'grok-4.7', effort: 'high' }) + expect(options.models.map((model) => model.id)).toEqual(['grok-4.7', 'grok-4.6']) + expect(options.models[0]?.efforts.map((effort) => effort.value)).toEqual(['high', 'low']) + await rig.adapter.setOption({ sessionId: SESSION, key: 'model', value: 'grok-4.6', fence: 1 }) + expect(rig.sent('session/set_config_option')[0]?.params).toMatchObject({ + configId: 'model', + value: 'grok-4.6' + }) + await expect( + rig.adapter.setOption({ sessionId: SESSION, key: 'fastMode', value: 'true', fence: 1 }) + ).rejects.toThrow(/no session option/) + }) + + it('reports the agent own slash commands', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + expect(rig.adapter.readCommands(SESSION)).toBeUndefined() + rig.child().agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'compact', description: 'Compress history', input: null }, + { name: 'always-approve', description: 'Toggle', input: { hint: 'on|off' } } + ] + } + }) + await waitFor(() => + expect(rig.adapter.readCommands(SESSION)).toEqual([ + { name: 'compact', kind: 'command', description: 'Compress history' }, + { name: 'always-approve', kind: 'command', description: 'Toggle', argumentHint: 'on|off' } + ]) + ) + }) +}) + +describe('ACP structured session adapter: close and exit', () => { + it('closes with proof and reports a requested close', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(rig.lifecycle).toMatchObject([ + { type: 'ended', sessionId: SESSION, cause: 'requested-close', fence: 1 } + ]) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(rig.lifecycle).toHaveLength(1) + }) + + it('ends the session on an unexpected exit: held sends rejected, the running one unknown', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + await rig.frame('session/prompt') + await send(rig, 'send-2') + rig.child().stderr = 'grok: crashed' + rig.child().exit() + await waitFor(() => + expect(rig.lifecycle).toMatchObject([ + { + type: 'ended', + cause: 'unexpected-exit', + acquisitionGeneration: 'gen-acp', + failure: { kind: 'providerExited' } + } + ]) + ) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'send-2', state: 'rejected' }), + expect.objectContaining({ clientMessageId: 'send-1', state: 'unknown' }) + ]) + ) + await expect(send(rig, 'send-3')).rejects.toThrow(/no live grok child/) + }) +}) + +describe('Grok launch spec', () => { + it('never self-updates and asks for always-approve only with full access', () => { + expect(GROK.args({ fullAccess: false })).toEqual(['--no-auto-update', 'agent', '--no-leader', 'stdio']) + expect(GROK.args({ fullAccess: true })).toEqual([ + '--no-auto-update', + 'agent', + '--no-leader', + '--always-approve', + 'stdio' + ]) + expect(GROK.env).toEqual({ GROK_DISABLE_AUTOUPDATER: '1' }) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts new file mode 100644 index 000000000000..3048824e1021 --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -0,0 +1,222 @@ +// A structured chat over the Agent Client Protocol: one adapter per registered ACP agent, which +// the router drives like the Claude and Codex lanes. Rewind, compaction and goals are absent, so +// the chat hides them; everything else maps onto ACP methods. + +import { randomUUID } from 'node:crypto' +import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { + AgentSessionAcquisitionExitUnprovenError, + type AgentSessionAcquisition, + type AgentSessionDispatchOutcome, + type StructuredAgentSessionAcquireInput, + type StructuredAgentSessionAdapter, + type StructuredAgentSessionSetOptionInput +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' +import { acpAgentName, acquireAcpStructuredSession } from './acp-structured-acquire' +import { AcpRequestTimeoutError } from './acp-errors' +import type { AcpStructuredChild } from './acp-structured-child' +import { endAcpStructuredSession, type AcpStructuredSession } from './acp-structured-session' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' +import type { ContentBlock } from './generated/acp-protocol.generated' + +export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter { + private readonly sessions = new Map() + /** Children still starting, so a close during the acquire can stop them. */ + private readonly starting = new Map() + + constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {} + + // The child runs on this runtime's own machine; Windows needs process start-time proof. + supportsLocation = (location: AgentSessionExecutionLocation): boolean => + supportsCodexStructuredLocation(location, this.deps.isWindowsProcessStartTimeAvailable) + + async acquire(input: StructuredAgentSessionAcquireInput): Promise { + const sessionId = input.identity.sessionId + if (!(await this.closeSession(sessionId))) { + throw new AgentSessionAcquisitionExitUnprovenError( + new Error( + `the previous ${this.deps.spec.agent} child for ${sessionId} could not be stopped` + ) + ) + } + const generation = this.deps.mintGeneration?.() ?? randomUUID() + const tracked: { child: AcpStructuredChild | null } = { child: null } + try { + const { acquisition, session } = await acquireAcpStructuredSession({ + acquire: input, + deps: this.deps, + generation, + track: (started) => { + tracked.child = started + this.starting.set(sessionId, started) + }, + onExit: (session) => { + if (session && this.sessions.get(sessionId) === session) { + endAcpStructuredSession(session, this.now(), this.deps.onEvent) + } + }, + onSettled: (settlement) => this.deps.onDispatchSettledLate?.({ sessionId, ...settlement }), + forceClose: (id) => void this.forceCloseSession(id) + }) + this.sessions.set(sessionId, session) + return acquisition + } catch (error) { + if (tracked.child && !(await tracked.child.close().catch(() => false))) { + throw new AgentSessionAcquisitionExitUnprovenError(error) + } + throw error + } finally { + if (this.starting.get(sessionId) === tracked.child) { + this.starting.delete(sessionId) + } + } + } + + async dispatch(input: { + sessionId: string + clientMessageId: string + body: AgentJournalMessageItem + fence: number + requestedAt?: number + beforeDispatch?: () => Promise + }): Promise { + const session = this.live(input.sessionId) + const prompt = this.promptBlocks(input.body) + if (!prompt) { + return { + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('attachmentInvalid'), { + surface: 'rejection', + agentName: acpAgentName(session.spec.agent) + }) + } + } + await input.beforeDispatch?.() + session.turns.dispatch({ + clientMessageId: input.clientMessageId, + prompt, + requestedAt: input.requestedAt ?? this.now() + }) + // The write is the admission; the agent's first event for the turn settles it. + return { state: 'admitted' } + } + + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = async (input) => { + const session = this.live(input.sessionId) + const withdrew = session.turns.withdrawQueued() + session.prompts.cancelAll() + if (!session.turns.running) { + return withdrew + ? { cancelled: true } + : { cancelled: false, refusal: { turnNotRunning: true } } + } + try { + // Ends when the agent answers the prompt `cancelled`; open permissions are declined first. + await session.runtime.cancel() + return { cancelled: true } + } catch (error) { + if (error instanceof AcpRequestTimeoutError) { + // The agent never ended the turn; its connection is closed, so the child goes too. + void this.forceCloseSession(input.sessionId) + } + return { cancelled: false } + } + } + + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => + this.live(input.sessionId).prompts.answer(input) + + async setOption( + input: StructuredAgentSessionSetOptionInput + ): Promise>> { + const session = this.live(input.sessionId) + const write = session.options.write(input.key, input.value) + if (!write) { + throw new Error(`${session.spec.agent} offers no session option named ${input.key}`) + } + if (write.method === 'config') { + const result = await session.runtime.setConfigOption(write.configId, write.value) + session.options.adoptConfigOptions(result.configOptions) + } else { + await session.runtime.setModel(write.modelId) + session.options.adoptModel(write.modelId) + } + return session.options.reported() + } + + readOptions = async (input: { sessionId: string; fence: number }) => + this.live(input.sessionId).options.read() + + readCommands = (sessionId: string) => this.sessions.get(sessionId)?.options.readCommands() + + holdsDispatch = (sessionId: string): boolean => + this.sessions.get(sessionId)?.turns.holdsDispatch() ?? false + + // ACP has no way to stop one background task the agent started. + backgroundTaskStops: NonNullable = ( + sessionId + ) => + this.sessions.has(sessionId) ? { supportsTaskStop: false, supportsStopAll: false } : undefined + + closeSession = (sessionId: string): Promise => this.stop(sessionId, true) + disposeSession = (sessionId: string): Promise => this.stop(sessionId, true) + releaseAcquisition = (input: { sessionId: string }): Promise => + this.stop(input.sessionId, true) + /** After a sink failure: the exit is recovered as unexpected. */ + forceCloseSession = (sessionId: string): Promise => this.stop(sessionId, false) + + async closeAll(): Promise { + const ids = new Set([...this.sessions.keys(), ...this.starting.keys()]) + const proven = await Promise.all([...ids].map((sessionId) => this.closeSession(sessionId))) + if (proven.includes(false)) { + throw new Error('an ACP agent child could not be proven stopped') + } + } + + /** True only once the child is proven gone, or when this adapter runs none for the session. */ + private async stop(sessionId: string, requested: boolean): Promise { + const starting = this.starting.get(sessionId) + if (starting && !(await starting.close())) { + return false + } + const session = this.sessions.get(sessionId) + if (!session || session.ended) { + return true + } + session.closeRequested ||= requested + session.lane.flush() + const proven = await session.child.close() + if (proven) { + endAcpStructuredSession(session, session.exitObservedAt ?? this.now(), this.deps.onEvent) + } + return proven + } + + private promptBlocks(body: AgentJournalMessageItem): ContentBlock[] | null { + const blocks: ContentBlock[] = [] + for (const block of body.blocks) { + if (block.type !== 'text') { + // Images wait for an ACP image path; the chat offers none while `imagePrompts` is off. + return null + } + blocks.push({ type: 'text', text: block.text }) + } + return blocks + } + + private live(sessionId: string): AcpStructuredSession { + const session = this.sessions.get(sessionId) + if (!session || session.ended) { + throw new Error(`no live ${this.deps.spec.agent} child owns ${sessionId}`) + } + return session + } + + private now(): number { + return (this.deps.now ?? Date.now)() + } +} diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts new file mode 100644 index 000000000000..9388f9a64a7b --- /dev/null +++ b/src/main/acp/acp-structured-session.ts @@ -0,0 +1,90 @@ +// One live ACP child and what Orca keeps beside it, and how its traffic and its exit reach the +// journal and the host. + +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' +import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { UNVERIFIABLE_TURN_VERDICT } from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' +import type { AcpLaunchSpec } from './acp-launch-specs' +import type { AcpSessionEvent, AcpSessionRuntime } from './acp-session-runtime' +import type { AcpStructuredChild } from './acp-structured-child' +import type { AcpStructuredLane } from './acp-structured-lane' +import type { AcpStructuredOptions } from './acp-structured-options' +import type { AcpStructuredPrompts } from './acp-structured-prompts' +import type { AcpStructuredTurns } from './acp-structured-turns' + +export type AcpStructuredSession = { + sessionId: string + fence: number + acquisitionGeneration: string + spec: AcpLaunchSpec + child: AcpStructuredChild + runtime: AcpSessionRuntime + lane: AcpStructuredLane + prompts: AcpStructuredPrompts + options: AcpStructuredOptions + turns: AcpStructuredTurns + /** Orca asked this child to stop; its exit is then a requested close. */ + closeRequested: boolean + ended: boolean + exitObservedAt: number | null + unbindReadingControl?: () => void +} + +/** Every agent frame goes through here once the lane exists: options and commands first, so a + * read right after the frame sees them, then the journal. */ +export function routeAcpSessionEvent( + session: Pick, + event: AcpSessionEvent, + at: number +): void { + if (event.kind === 'known') { + const { update } = event.notification + if (update.sessionUpdate === 'available_commands_update') { + session.options.adoptCommands(update.availableCommands) + } else if (update.sessionUpdate === 'config_option_update') { + session.options.adoptConfigOptions(update.configOptions) + } + } + session.lane.apply(session.lane.translator.sessionEvent(event, at)) +} + +/** + * The child's exit, once. Open requests die with it, held sends never left Orca, the running turn + * is one the host never heard end (`unverifiable` until death evidence revises it), and the host + * hears `ended` so it releases the session. + */ +export function endAcpStructuredSession( + session: AcpStructuredSession, + observedAt: number, + onEvent: ((event: StructuredAgentSessionEndedEvent) => void) | undefined +): void { + if (session.ended) { + return + } + session.ended = true + session.exitObservedAt = observedAt + const stderr = session.child.stderrTail() + const reason = session.closeRequested + ? `${session.spec.agent} ACP agent closed by Orca` + : `${session.spec.agent} ACP agent exited${stderr ? `: ${stderr}` : ''}` + session.prompts.clear() + session.turns.end(reason) + session.lane.apply([{ type: 'session.ended', verdict: UNVERIFIABLE_TURN_VERDICT }]) + session.lane.flush() + session.lane.dispose() + session.unbindReadingControl?.() + session.runtime.close(new Error(reason)) + const detail = stderr ? providerDiagnostic(stderr, 'person') : undefined + onEvent?.({ + type: 'ended', + sessionId: session.sessionId, + reason, + failure: session.closeRequested + ? agentSessionFailureFact('hostFault') + : agentSessionFailureFact('providerExited', detail ? { detail } : {}), + cause: session.closeRequested ? 'requested-close' : 'unexpected-exit', + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration, + observedAt + }) +} diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts new file mode 100644 index 000000000000..19427b93153a --- /dev/null +++ b/src/main/acp/acp-structured-turns.ts @@ -0,0 +1,152 @@ +// The sends of one ACP session. ACP runs one prompt at a time, so a message sent while a turn runs +// is held here and sent as the next prompt once the running one is answered. Each send is settled +// exactly once: accepted when the agent's first event for its turn (or its answer) arrives, +// rejected when the agent refused it or it never left Orca, unknown when the agent died with it. + +import { + agentSessionFailureFact, + providerDiagnostic, + type SubmissionRejectionFact +} from '../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection +} from '../../shared/agent-session-failure-words' +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { AcpRpcError } from './acp-errors' +import type { AcpSessionRuntime } from './acp-session-runtime' +import type { AcpStructuredLane } from './acp-structured-lane' +import type { ContentBlock } from './generated/acp-protocol.generated' + +export type AcpDispatchSettlement = { clientMessageId: string } & ( + | { providerIdentity: AgentJournalItemIdentity } + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason: string } +) + +type Send = { clientMessageId: string; prompt: ContentBlock[]; requestedAt: number } + +export type AcpStructuredTurnsDeps = { + runtime: Pick + lane: AcpStructuredLane + agentName: string + now: () => number + settle: (settlement: AcpDispatchSettlement) => void +} + +export class AcpStructuredTurns { + private active: Send | null = null + private readonly queue: Send[] = [] + private readonly unsettled = new Set() + private ended = false + + constructor(private readonly deps: AcpStructuredTurnsDeps) {} + + get running(): boolean { + return this.active !== null + } + + /** A send the agent has neither answered nor started: one held here, or one it has not echoed. */ + holdsDispatch(): boolean { + return ( + this.queue.length > 0 || + (this.active !== null && this.unsettled.has(this.active.clientMessageId)) + ) + } + + dispatch(send: Send): void { + this.unsettled.add(send.clientMessageId) + if (this.active) { + this.queue.push(send) + return + } + this.start(send) + } + + /** The agent took the send: its turn's first event, or its answer, arrived. */ + accept(clientMessageId: string): void { + if (this.unsettled.delete(clientMessageId)) { + this.deps.settle({ + clientMessageId, + providerIdentity: { provider: 'orca', clientMessageId } + }) + } + } + + /** A Stop: held sends never reach the agent. */ + withdrawQueued(): boolean { + const withdrawn = this.queue.splice(0) + for (const send of withdrawn) { + this.reject(send.clientMessageId, agentSessionFailureFact('cancelled')) + } + return withdrawn.length > 0 + } + + /** The child is gone: held sends never left Orca, and the running one's fate is unknown. */ + end(reason: string): void { + this.ended = true + for (const send of this.queue.splice(0)) { + this.reject(send.clientMessageId, agentSessionFailureFact('providerExited')) + } + const active = this.active + if (active && this.unsettled.delete(active.clientMessageId)) { + this.deps.settle({ clientMessageId: active.clientMessageId, state: 'unknown', reason }) + } + this.active = null + } + + private start(send: Send): void { + const { lane } = this.deps + this.active = send + const opened = lane.translator.openPrompt(send.clientMessageId, send.requestedAt) + lane.apply(opened.events) + // The agent echoes this id on every event of the turn, so its rows join the turn Orca opened. + const meta = { promptId: opened.promptId, requestId: opened.promptId } + this.deps.runtime.prompt(send.prompt, meta).then( + (result) => { + if (this.active !== send) { + return + } + lane.apply(lane.translator.promptResult(send.clientMessageId, result, this.deps.now())) + this.accept(send.clientMessageId) + this.finish(send) + }, + (error: unknown) => { + if (this.active !== send) { + return + } + lane.apply(lane.translator.promptFailed(send.clientMessageId, error, this.deps.now())) + if (error instanceof AcpRpcError && this.unsettled.has(send.clientMessageId)) { + // The agent answered the prompt with an error before starting it: its own refusal. + const detail = providerDiagnostic(error.message, 'person') + this.reject( + send.clientMessageId, + agentSessionFailureFact('providerRejected', detail ? { detail } : {}) + ) + } + this.finish(send) + } + ) + } + + private finish(send: Send): void { + if (this.active === send) { + this.active = null + } + const next = this.ended ? undefined : this.queue.shift() + if (next) { + this.start(next) + } + } + + private reject(clientMessageId: string, fact: SubmissionRejectionFact): void { + if (!this.unsettled.delete(clientMessageId)) { + return + } + this.deps.settle({ + clientMessageId, + state: 'rejected', + ...agentSessionFailureWords(fact, { surface: 'rejection', agentName: this.deps.agentName }) + }) + } +} diff --git a/src/main/codex/codex-structured-owner-identity.ts b/src/main/codex/codex-structured-owner-identity.ts index 9b0d3e8e5488..e14be03ca109 100644 --- a/src/main/codex/codex-structured-owner-identity.ts +++ b/src/main/codex/codex-structured-owner-identity.ts @@ -5,7 +5,11 @@ import { } from '../../shared/agent-session-provider-handle' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' -import { readProcessStartTimeMs } from '../runtime/agent-session-process-identity-probe' +import { + PROVIDER_SPAWN_TOKEN_ENV, + providerProcessIdentity, + providerSpawnedProcessIdentity +} from '../provider-process/provider-spawned-process-identity' // What the lease records about the child Codex just handed back: the process it // will later re-prove, and the provider handle link the journal binds to. Both @@ -14,15 +18,10 @@ import { readProcessStartTimeMs } from '../runtime/agent-session-process-identit /** The child echoes its spawn token here so the owner probe can tell a live * child of THIS reservation from a same-pid stranger. */ -export const CODEX_SPAWN_TOKEN_ENV = 'ORCA_AGENT_SESSION_SPAWN_TOKEN' +export const CODEX_SPAWN_TOKEN_ENV = PROVIDER_SPAWN_TOKEN_ENV -const START_TIME_READ_ATTEMPTS = 3 +const CODEX_PROCESS_LABEL = 'codex app-server' -/** - * The child's identity, read once. The real connection reports its spawn before the handshake, and - * `onSpawned` makes it durable there, so a crash mid-start leaves an owner recovery can stop; a - * connection that reports no spawn is identified once it is open. - */ export function codexSpawnedProcessIdentity( input: { identity: AgentSessionJournalIdentity @@ -30,50 +29,19 @@ export function codexSpawnedProcessIdentity( onSpawned?: (process: AgentSessionProcessIdentity) => Promise }, readStartTime?: (pid: number) => Promise -): { - onSpawned: (pid: number) => Promise - read: (pid: number | undefined) => Promise -} { - let spawned: Promise | undefined - return { - onSpawned: async (pid) => { - spawned = codexProcessIdentity({ ...input, pid }, readStartTime) - await input.onSpawned?.(await spawned) - }, - read: (pid) => spawned ?? codexProcessIdentity({ ...input, pid }, readStartTime) - } +): ReturnType { + return providerSpawnedProcessIdentity(input, CODEX_PROCESS_LABEL, readStartTime) } -export async function codexProcessIdentity( +export function codexProcessIdentity( input: { identity: AgentSessionJournalIdentity spawnToken: string pid: number | undefined }, - readStartTime: (pid: number) => Promise = readProcessStartTimeMs + readStartTime?: (pid: number) => Promise ): Promise { - if (input.pid === undefined) { - throw new Error('codex app-server started without a pid') - } - let processStartTimeMs: number | null = null - for ( - let attempt = 0; - attempt < START_TIME_READ_ATTEMPTS && processStartTimeMs === null; - attempt += 1 - ) { - processStartTimeMs = await readStartTime(input.pid) - } - if (processStartTimeMs === null) { - // Why: recording null makes every later owner probe indeterminate — a durable latch. - // Failing here reaps the child and leaves a retryable refusal instead. - throw new Error(`codex app-server start time for pid ${input.pid} could not be read`) - } - return { - hostId: input.identity.hostId, - pid: input.pid, - processStartTimeMs, - spawnToken: input.spawnToken - } + return providerProcessIdentity(input, CODEX_PROCESS_LABEL, readStartTime) } type CodexProviderHandleLinkInput = { diff --git a/src/main/provider-process/provider-spawned-process-identity.ts b/src/main/provider-process/provider-spawned-process-identity.ts new file mode 100644 index 000000000000..b0f04b29f27c --- /dev/null +++ b/src/main/provider-process/provider-spawned-process-identity.ts @@ -0,0 +1,69 @@ +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { readProcessStartTimeMs } from '../runtime/agent-session-process-identity-probe' + +// What the lease records about a provider child: the process a later owner probe re-proves. + +/** The child echoes its spawn token here so the owner probe can tell a live child of THIS + * reservation from a same-pid stranger. */ +export const PROVIDER_SPAWN_TOKEN_ENV = 'ORCA_AGENT_SESSION_SPAWN_TOKEN' + +const START_TIME_READ_ATTEMPTS = 3 + +type SpawnedIdentityInput = { + identity: AgentSessionJournalIdentity + spawnToken: string + onSpawned?: (process: AgentSessionProcessIdentity) => Promise +} + +/** + * The child's identity, read once. A connection that reports its spawn before the handshake makes + * it durable there through `onSpawned`, so a crash mid-start leaves an owner recovery can stop; one + * that reports no spawn is identified once it is open. + */ +export function providerSpawnedProcessIdentity( + input: SpawnedIdentityInput, + label: string, + readStartTime?: (pid: number) => Promise +): { + onSpawned: (pid: number) => Promise + read: (pid: number | undefined) => Promise +} { + let spawned: Promise | undefined + return { + onSpawned: async (pid) => { + spawned = providerProcessIdentity({ ...input, pid }, label, readStartTime) + await input.onSpawned?.(await spawned) + }, + read: (pid) => spawned ?? providerProcessIdentity({ ...input, pid }, label, readStartTime) + } +} + +export async function providerProcessIdentity( + input: { identity: AgentSessionJournalIdentity; spawnToken: string; pid: number | undefined }, + label: string, + readStartTime: (pid: number) => Promise = readProcessStartTimeMs +): Promise { + if (input.pid === undefined) { + throw new Error(`${label} started without a pid`) + } + let processStartTimeMs: number | null = null + for ( + let attempt = 0; + attempt < START_TIME_READ_ATTEMPTS && processStartTimeMs === null; + attempt += 1 + ) { + processStartTimeMs = await readStartTime(input.pid) + } + if (processStartTimeMs === null) { + // Why: recording null makes every later owner probe indeterminate — a durable latch. + // Failing here reaps the child and leaves a retryable refusal instead. + throw new Error(`${label} start time for pid ${input.pid} could not be read`) + } + return { + hostId: input.identity.hostId, + pid: input.pid, + processStartTimeMs, + spawnToken: input.spawnToken + } +} diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 4e799f6f1f38..be57032df051 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -24,6 +24,11 @@ import type { createStructuredAgentSessionDispatchFollowUps } from './structured import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' import type { createStructuredAgentEnvironmentResolvers } from './structured-agent-shell-environment' import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' +import { ACP_LAUNCH_SPECS, type AcpLaunchSpec } from '../acp/acp-launch-specs' +import { acpStructuredAgentDefinition } from '../acp/acp-structured-agent-definitions' +import { spawnAcpStructuredChild } from '../acp/acp-structured-child' +import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch-resolution' +import { AcpStructuredSessionAdapter } from '../acp/acp-structured-session-adapter' /** What an agent's adapter is built from: the open store and the runtime around it. */ export type StructuredAgentAdapterContext = { @@ -109,10 +114,40 @@ function createClaudeAdapter( }) } +function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistration { + return { + definition: acpStructuredAgentDefinition(spec), + createAdapter: (context) => { + const { deps, store, followUps } = context + return new AcpStructuredSessionAdapter({ + spec, + resolveLaunch: createAcpStructuredLaunchResolver(spec, { + store, + resolveWorkspacePath: deps.resolveWorkspacePath, + resolveEnvironment: context.environment.resolveBaseEnvironment, + ...(deps.resolveAgentLaunchEnv ? { resolveLaunchEnv: deps.resolveAgentLaunchEnv } : {}), + ...(deps.resolveAgentFullAccess ? { resolveFullAccess: deps.resolveAgentFullAccess } : {}) + }), + spawnChild: deps.spawnAcpChild ?? ((launch) => spawnAcpStructuredChild(launch)), + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), + onDispatchSettledLate: followUps.onDispatchSettledLate, + logger: deps.logger, + // Every exit, expected or not: the host ends that child's record. + onEvent: (event) => { + if (event.type === 'ended') { + context.deliverLifecycle(event) + } + } + }) + } + } +} + export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRuntimeRegistration[] = [ { definition: CODEX_STRUCTURED_AGENT, createAdapter: createCodexAdapter }, - { definition: CLAUDE_STRUCTURED_AGENT, createAdapter: createClaudeAdapter } + { definition: CLAUDE_STRUCTURED_AGENT, createAdapter: createClaudeAdapter }, + ...ACP_LAUNCH_SPECS.map(acpRegistration) ] /** What the record store admits: exactly the registered agents' declared storage. */ diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 055530627466..f8cbea35501a 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -59,6 +59,7 @@ import { modelCatalogHostDeps, type RuntimeAgentAccountHomeResolver } from './structured-agent-model-catalog-wiring' +import type { SpawnAcpStructuredChild } from '../acp/acp-structured-child' /** Whether this profile holds a structured chat: a record or tab in the journal database, or the * records file a profile from before it carries while the database still owes its copy. */ @@ -108,6 +109,12 @@ export type StructuredAgentSessionRuntimeDeps = { resolveClaudePermissionMode?: () => Promise | PermissionMode /** The same setting for Codex, as app-server thread policy. */ resolveCodexPermissionPolicy?: () => CodexStructuredPermissionPolicy + /** The same setting for a protocol-driven (ACP) agent: whether it runs with full access. */ + resolveAgentFullAccess?: (agent: string) => boolean + /** The user's per-agent environment overlay, for agents with no lane-specific resolver. */ + resolveAgentLaunchEnv?: (agent: string) => Record + /** ACP agent processes are overridden only to drive the runtime against scripted agents. */ + spawnAcpChild?: SpawnAcpStructuredChild /** Raw settings getter; the reader that fails closed around it is built here, in checked code. */ getClaudeManagedAccountGateSettings?: () => ClaudeManagedAccountGateSettings resolveEnvironment?: () => Promise diff --git a/src/main/runtime/structured-agent-shell-environment.ts b/src/main/runtime/structured-agent-shell-environment.ts index 7233772445a6..ed0d628d3d11 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -91,6 +91,8 @@ export function createStructuredAgentEnvironmentResolvers( ): { resolveCodexEnvironment: () => Promise resolveClaudeInheritedEnv: () => Promise> + /** The shared base every agent's child env starts from, before its own overlay. */ + resolveBaseEnvironment: () => Promise> } { const shellEnvironment = (sources.resolveEnvironment ?? resolveLoginShellEnvironment)() const resolveBase = async (): Promise> => @@ -105,6 +107,7 @@ export function createStructuredAgentEnvironmentResolvers( ...(await sources.resolveLaunchEnvOverlay?.()), ...sources.resolveCodexOverrides?.() }), - resolveClaudeInheritedEnv: resolveBase + resolveClaudeInheritedEnv: resolveBase, + resolveBaseEnvironment: resolveBase } } From 67e7fab4ff25d91e33dcb154033372d875522a31 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:06:08 -0700 Subject: [PATCH 083/269] Separate provider root and descendant exit observations --- .../claude-agent-sdk-exit-proof.test.ts | 54 ++++------ .../claude/claude-agent-sdk-exit-proof.ts | 4 +- .../claude-agent-sdk-process-spawn.test.ts | 27 ++--- .../claude/claude-agent-sdk-process-spawn.ts | 5 +- .../claude/claude-child-exit-proof-fixture.ts | 31 ++++++ .../claude-child-exit-proof-ladder.test.ts | 100 ++++++++++-------- .../claude/claude-child-exit-proof-ladder.ts | 29 +---- .../claude-stream-json-connection.test.ts | 4 +- .../claude/claude-stream-json-connection.ts | 32 +++--- ...ed-session-acquisition-processless.test.ts | 2 +- .../claude/claude-structured-session-close.ts | 4 +- ...claude-supervised-stop.integration.test.ts | 34 +++--- .../codex/codex-app-server-connection.test.ts | 2 + src/main/codex/codex-app-server-connection.ts | 16 +-- .../managed-provider-process-grace.test.ts | 73 +++++++++++++ .../managed-provider-process.test.ts | 81 +++++++++----- .../managed-provider-process.ts | 54 +++++----- .../provider-process-close.ts | 26 +++-- .../retryable-process-exit-proof.test.ts | 6 +- .../retryable-process-exit-proof.ts | 6 +- 20 files changed, 343 insertions(+), 247 deletions(-) create mode 100644 src/main/claude/claude-child-exit-proof-fixture.ts create mode 100644 src/main/provider-process/managed-provider-process-grace.test.ts diff --git a/src/main/claude/claude-agent-sdk-exit-proof.test.ts b/src/main/claude/claude-agent-sdk-exit-proof.test.ts index 8ce2feb9ad54..a6abfedf3a31 100644 --- a/src/main/claude/claude-agent-sdk-exit-proof.test.ts +++ b/src/main/claude/claude-agent-sdk-exit-proof.test.ts @@ -11,6 +11,7 @@ import { proveClaudeChildExit, type ClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' +import { managedChild } from './claude-child-exit-proof-fixture' import { GRACEFUL_EXIT_MS } from './claude-child-exit-proof-ladder' // The descendant models an MCP server: it either cooperates or, when it traps @@ -99,11 +100,13 @@ async function proveExitWithRetries( } function spawnScript(script: string): ReturnType { - return spawnProcess({ + const child = spawnProcess({ program: process.execPath, args: ['-e', script], stdio: ['pipe', 'pipe', 'pipe'] }) + managedChild(child) + return child } function firstStdoutLine(child: ReturnType): Promise { @@ -112,17 +115,6 @@ function firstStdoutLine(child: ReturnType): Promise; exited: () => boolean } { - let exited = false - const exitPromise = new Promise((resolve) => { - child.once('exit', () => { - exited = true - resolve() - }) - }) - return { exitPromise, exited: () => exited } -} - /** `null` models a spawn that failed before a pid existed. */ function mockChild( pid: number | null = 424242 @@ -200,7 +192,7 @@ describe('claude child exit proof', () => { await ageDescendantPastTheCaptureSecond() try { - const proven = await proveExitWithRetries({ child, ...observeExit(child) }) + const proven = await proveExitWithRetries({ managed: managedChild(child) }) // Evaluated AT the boundary, not by polling until a deferred sweep timer // wins: true releases the lease, so a descendant still running here is // exactly the orphan the proof exists to prevent. False would be the @@ -237,7 +229,7 @@ describe('claude child exit proof', () => { await ageDescendantPastTheCaptureSecond() try { - const proven = await proveExitWithRetries({ child, ...observeExit(child) }) + const proven = await proveExitWithRetries({ managed: managedChild(child) }) expect({ proven, descendant: descendantState(descendantPid) }).toEqual({ proven: true, descendant: 'exited' @@ -263,7 +255,7 @@ describe('claude child exit proof', () => { descendantPid: number } try { - const proven = await proveExitWithRetries({ child, ...observeExit(child) }) + const proven = await proveExitWithRetries({ managed: managedChild(child) }) expect({ proven, descendant: descendantState(descendantPid) }).toEqual({ proven: true, descendant: 'exited' @@ -282,26 +274,26 @@ describe('claude child exit proof', () => { it('arms the snapshot before stdin closes and verifies it after a clean exit', async () => { const child = spawnScript(COOPERATIVE_CHILD) expect(await firstStdoutLine(child)).toBe('ready') - const exit = observeExit(child) + const managed = managedChild(child) const tree = mockTree(['exited']) let exitedWhenArmed: boolean | null = null tree.capture.mockImplementation(async () => { - exitedWhenArmed = exit.exited() + exitedWhenArmed = managed.rootVerdict === 'exited' }) - await expect(proveClaudeChildExit({ child, ...exit, tree })).resolves.toBe(true) + await expect(proveClaudeChildExit({ managed, tree })).resolves.toBe(true) // The snapshot is the only proof that survives the root: taken while it lived, // verified once it left. A reap before the exit would have been the forced ladder. expect(exitedWhenArmed).toBe(false) expect(tree.reap).toHaveBeenCalledTimes(1) - expect(exit.exited()).toBe(true) + expect(managed.rootVerdict).toBe('exited') }, 20_000) it('proves a clean close of a childless root with one snapshot and no signal', async () => { const child = spawnScript(COOPERATIVE_CHILD) expect(await firstStdoutLine(child)).toBe('ready') - await expect(proveClaudeChildExit({ child, ...observeExit(child) })).resolves.toBe(true) + await expect(proveClaudeChildExit({ managed: managedChild(child) })).resolves.toBe(true) }, 20_000) it('reports an unprovable exit as false rather than assuming the child died', async () => { @@ -309,9 +301,7 @@ describe('claude child exit proof', () => { try { const tree = mockTree(['exited']) const proof = proveClaudeChildExit({ - child: mockChild(), - exitPromise: new Promise(() => {}), - exited: () => false, + managed: managedChild(mockChild()), tree }) await new Promise((resolve) => setImmediate(resolve)) @@ -330,18 +320,18 @@ describe('claude child exit proof', () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) try { const child = mockChild() - const exit = observeExit(child) + const managed = managedChild(child) const tree = mockTree(['live']) tree.reap.mockImplementation(async () => { child.emit('exit', null, 'SIGKILL') return 'live' }) - const proof = proveClaudeChildExit({ child, ...exit, tree }) + const proof = proveClaudeChildExit({ managed, tree }) await new Promise((resolve) => setImmediate(resolve)) await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS) await expect(proof).resolves.toBe(false) - expect(exit.exited()).toBe(true) + expect(managed.rootVerdict).toBe('exited') // One verification per attempt: the retried close re-verifies, this one does not. expect(tree.reap).toHaveBeenCalledTimes(1) expect(vi.getTimerCount()).toBe(0) @@ -352,16 +342,18 @@ describe('claude child exit proof', () => { it('re-verifies an unproven tree on a retried close instead of trusting the dead root', async () => { const child = mockChild() + const managed = managedChild(child) + child.emit('exit', 0, null) const tree = mockTree(['exited']) - await expect( - proveClaudeChildExit({ child, exitPromise: Promise.resolve(), exited: () => true, tree }) - ).resolves.toBe(true) + await expect(proveClaudeChildExit({ managed, tree })).resolves.toBe(true) expect(tree.reap).toHaveBeenCalledTimes(1) }) it('stays unproven for a root that left before any snapshot could be armed', async () => { const child = mockChild() + const managed = managedChild(child) + child.emit('exit', 0, null) const captureDescendants = vi.fn(async () => snapshotOf(4243)) const terminateDescendants = vi.fn() const tree = createClaudeChildTreeReaper(child, { @@ -371,9 +363,7 @@ describe('claude child exit proof', () => { terminateDescendants }) - await expect( - proveClaudeChildExit({ child, exitPromise: Promise.resolve(), exited: () => true, tree }) - ).resolves.toBe(false) + await expect(proveClaudeChildExit({ managed, tree })).resolves.toBe(false) // A dead root's descendants have reparented: walking its pid now could only // sweep a stranger, so no walk is attempted and nothing is proven. expect(captureDescendants).not.toHaveBeenCalled() diff --git a/src/main/claude/claude-agent-sdk-exit-proof.ts b/src/main/claude/claude-agent-sdk-exit-proof.ts index 883bf8efd6ac..82db647cb5a7 100644 --- a/src/main/claude/claude-agent-sdk-exit-proof.ts +++ b/src/main/claude/claude-agent-sdk-exit-proof.ts @@ -361,6 +361,8 @@ export function createClaudeChildTreeReaper( */ export function proveClaudeChildExit(input: ClaudeChildExitProofInput): Promise { return proveClaudeChildExitWithReaper(input, () => - createClaudeChildTreeReaper(input.child, { exited: input.exited }) + createClaudeChildTreeReaper(input.managed.child, { + exited: () => input.managed.rootVerdict === 'exited' + }) ) } diff --git a/src/main/claude/claude-agent-sdk-process-spawn.test.ts b/src/main/claude/claude-agent-sdk-process-spawn.test.ts index af1da7cd8bf0..7838cebcd282 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.test.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.test.ts @@ -121,32 +121,21 @@ describe('claude agent SDK process spawn', () => { spawn.spawn(sdkOptions()) expect(spawn.supervised).toBe(specSupervised) - let exited = false - let settle = (): void => {} - const exitPromise = new Promise((resolve) => { - settle = resolve - }) - // Claude leaves shortly after stdin ends, so the ladder never needs its forced rung. + const managed = spawn.managed + if (!managed) { + throw new Error('Claude spawner did not retain its managed child') + } + // Claude leaves shortly after stdin ends, before the forced stop. process.child.stdin.on('finish', () => - setTimeout(() => { - exited = true - settle() - }, 10) + setTimeout(() => process.child.emit('exit', 0, null), 10) ) const tree = { capture: vi.fn(async () => {}), reap: vi.fn(async () => 'exited' as const), treeVerdict: 'exited' as const } - await proveClaudeChildExitWithReaper( - { - child: process.child, - exitPromise, - exited: () => exited, - tree, - supervised: spawn.supervised - }, - () => tree + await expect(proveClaudeChildExitWithReaper({ managed, tree }, () => tree)).resolves.toBe( + true ) // SIGTERM to an unsupervised Claude on Windows is TerminateProcess; a skipped one leaves it running. if (specSupervised) { diff --git a/src/main/claude/claude-agent-sdk-process-spawn.ts b/src/main/claude/claude-agent-sdk-process-spawn.ts index 88886415cdb7..92ebb8f1d3d3 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.ts @@ -55,7 +55,7 @@ export function createClaudeCodeProcessSpawn( let stderrTail = '' return { spawn: (options) => { - // The SDK's abort signal cannot bypass Orca's child-owned close. + // SDK abort would kill the child outside Orca's ladder, losing observed exit proof. managed = spawnManagedProviderProcess( { command: options.command, @@ -67,7 +67,8 @@ export function createClaudeCodeProcessSpawn( platform, inheritedEnv: definedEnv(options.env), site: 'claude-stream-json-teardown', - policy: claudeChildClosePolicy + policy: claudeChildClosePolicy, + acceptClose: (result) => result.root === 'exited' && result.tree === 'exited' } ) const spawned = managed.child diff --git a/src/main/claude/claude-child-exit-proof-fixture.ts b/src/main/claude/claude-child-exit-proof-fixture.ts new file mode 100644 index 000000000000..7d03431d5316 --- /dev/null +++ b/src/main/claude/claude-child-exit-proof-fixture.ts @@ -0,0 +1,31 @@ +import type { EventEmitter } from 'node:events' +import type { spawnProcess, SpawnedProcess } from '../../shared/child-process/run-process' +import { + spawnManagedProviderProcess, + type ManagedProviderProcess +} from '../provider-process/managed-provider-process' +import { claudeChildClosePolicy } from './claude-child-exit-proof-ladder' + +const managedChildren = new WeakMap() + +export function managedChild( + child: Pick & EventEmitter +): ManagedProviderProcess { + const existing = managedChildren.get(child) + if (existing) { + return existing + } + const managed = spawnManagedProviderProcess( + { command: 'fixture-provider', args: [] }, + { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Close reads only the fixture's owned pid, events, kill and stdin. + spawnImpl: () => child as ReturnType, + platform: 'win32', + site: 'claude-proof-fixture', + policy: claudeChildClosePolicy, + acceptClose: (result) => result.root === 'exited' && result.tree === 'exited' + } + ) + managedChildren.set(child, managed) + return managed +} diff --git a/src/main/claude/claude-child-exit-proof-ladder.test.ts b/src/main/claude/claude-child-exit-proof-ladder.test.ts index 6a74a0b0c6b6..e327d361d58d 100644 --- a/src/main/claude/claude-child-exit-proof-ladder.test.ts +++ b/src/main/claude/claude-child-exit-proof-ladder.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../shared/child-process/run-process' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' import type { ClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' +import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' import { proveClaudeChildExitWithReaper } from './claude-child-exit-proof-ladder' function fakeTree(): ClaudeChildTreeReaper & { reap: ReturnType } { @@ -12,61 +16,65 @@ function fakeTree(): ClaudeChildTreeReaper & { reap: ReturnType } } } -/** A root that leaves only once a SIGTERM has had `stopMs` to act, the way a supervisor does. */ -function rootStoppedBySigterm(stopMs: number) { - let exited = false - let settle = (): void => {} - const exitPromise = new Promise((resolve) => { - settle = resolve +function rootStoppedBySigterm(stopMs: number, platform: NodeJS.Platform) { + const child = Object.assign(new EventEmitter(), { + pid: 4321, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn((signal?: NodeJS.Signals | number) => { + if (signal === 'SIGTERM') { + setTimeout(() => child.emit('exit', 0, 'SIGTERM'), stopMs) + } + return true + }) }) - const kill = vi.fn((signal?: NodeJS.Signals | number) => { - if (signal === 'SIGTERM') { - setTimeout(() => { - exited = true - settle() - }, stopMs) - } - return true + const spawner = createClaudeCodeProcessSpawn(() => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies every event, stream and process field used by the spawner and close. + return child as unknown as ReturnType + }, platform) + spawner.spawn({ + command: 'fixture-provider', + args: [], + env: {}, + signal: new AbortController().signal }) - const stdin = { end: vi.fn() } - return { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The ladder reads only pid, kill and stdin.end from its child. - child: { pid: 4321, kill, stdin } as unknown as Parameters< - typeof proveClaudeChildExitWithReaper - >[0]['child'], - kill, - stdin, - exitPromise, - exited: () => exited + const managed = spawner.managed + if (!managed) { + throw new Error('Fixture did not retain its managed child') } + return { child, managed } } +afterEach(() => vi.useRealTimers()) + describe('Claude child exit proof ladder', () => { it('stops a supervised child with SIGTERM and waits out the supervisor stop before forcing', async () => { - // Slower than the unsupervised 1.5 s grace, still inside the supervisor's own bound. - const root = rootStoppedBySigterm(PROVIDER_SUPERVISOR_MAX_STOP_MS - 500) + vi.useFakeTimers() + const root = rootStoppedBySigterm(PROVIDER_SUPERVISOR_MAX_STOP_MS - 500, 'darwin') const tree = fakeTree() - - await expect( - proveClaudeChildExitWithReaper({ ...root, supervised: true, tree }, () => tree) - ).resolves.toBe(true) - - expect(root.stdin.end).toHaveBeenCalled() - expect(root.kill).toHaveBeenCalledWith('SIGTERM') - // Forcing here would SIGKILL the supervisor mid-stop and orphan Claude in its own group. - expect(root.kill).not.toHaveBeenCalledWith('SIGKILL') + expect(root.managed.rootVerdict).toBe('live') + const proof = proveClaudeChildExitWithReaper({ managed: root.managed, tree }, () => tree) + await vi.advanceTimersByTimeAsync(PROVIDER_SUPERVISOR_MAX_STOP_MS) + await expect(proof).resolves.toBe(true) + expect(root.child.stdin.writableEnded).toBe(true) + expect(root.child.kill).toHaveBeenCalledExactlyOnceWith('SIGTERM') + expect(root.managed.lastCloseResult).toEqual({ root: 'exited', tree: 'exited' }) expect(tree.reap).not.toHaveBeenCalled() - }, 10_000) + expect(vi.getTimerCount()).toBe(0) + }) it('never signals an unsupervised child for the graceful stop', async () => { - const root = rootStoppedBySigterm(0) + vi.useFakeTimers() + const root = rootStoppedBySigterm(0, 'win32') const tree = fakeTree() - - await proveClaudeChildExitWithReaper({ ...root, tree }, () => tree) - - // On Windows a direct SIGTERM is TerminateProcess: stdin end stays the only graceful rung. - expect(root.stdin.end).toHaveBeenCalled() - expect(root.kill).not.toHaveBeenCalledWith('SIGTERM') - expect(tree.reap).toHaveBeenCalled() - }, 10_000) + const proof = proveClaudeChildExitWithReaper({ managed: root.managed, tree }, () => tree) + await vi.advanceTimersByTimeAsync(2_500) + await expect(proof).resolves.toBe(false) + expect(root.child.stdin.writableEnded).toBe(true) + expect(root.child.kill).not.toHaveBeenCalledWith('SIGTERM') + expect(tree.reap).toHaveBeenCalledOnce() + expect(root.managed.lastCloseResult).toEqual({ root: 'live', tree: 'exited' }) + expect(vi.getTimerCount()).toBe(0) + }) }) diff --git a/src/main/claude/claude-child-exit-proof-ladder.ts b/src/main/claude/claude-child-exit-proof-ladder.ts index 67efa47cef45..a3f1de842364 100644 --- a/src/main/claude/claude-child-exit-proof-ladder.ts +++ b/src/main/claude/claude-child-exit-proof-ladder.ts @@ -1,10 +1,6 @@ -import type { SpawnedProcess } from '../../shared/child-process/run-process' -import { - closeProviderProcess, - type ProviderProcessClosePolicy -} from '../provider-process/provider-process-close' import type { ManagedProviderProcess } from '../provider-process/managed-provider-process' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' +import type { ProviderProcessClosePolicy } from '../provider-process/provider-process-close' import type { ClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' export const GRACEFUL_EXIT_MS = 1_500 @@ -16,34 +12,19 @@ export function claudeChildClosePolicy(supervised: boolean): ProviderProcessClos return { gracefulExitMs: supervised ? SUPERVISED_GRACEFUL_EXIT_MS : GRACEFUL_EXIT_MS, forcedExitMs: FORCED_EXIT_MS, - signalSupervisorOnClose: true, - requireTreeExit: true + signalSupervisorOnClose: true } } export type ClaudeChildExitProofInput = { - child: Pick - exitPromise: Promise - exited: () => boolean + managed: ManagedProviderProcess tree?: ClaudeChildTreeReaper - managed?: ManagedProviderProcess - /** The child is the POSIX provider supervisor: SIGTERM stops Claude, which reaps its tools. */ - supervised?: boolean } export async function proveClaudeChildExitWithReaper( input: ClaudeChildExitProofInput, createTree: () => ClaudeChildTreeReaper ): Promise { - const tree = input.tree ?? createTree() - if (input.managed) { - return (await input.managed.close(tree)) === 'exited' - } - const result = await closeProviderProcess({ - ...input, - tree, - policy: claudeChildClosePolicy(input.supervised ?? false), - terminateTree: async () => false - }) - return result.verdict === 'exited' + const result = await input.managed.close(input.tree ?? createTree()) + return result.root === 'exited' && result.tree === 'exited' } diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 082ed4c12ce7..857f687d0b77 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -705,7 +705,7 @@ describe('Claude stream-json connection', () => { ) await until( - () => (connection.exitVerdict.root === 'processless' ? connection.exitVerdict : null), + () => (connection.exitVerdict.processless === true ? connection.exitVerdict : null), 'the processless spawn settlement' ) expect(connection.pid).toBeUndefined() @@ -716,7 +716,7 @@ describe('Claude stream-json connection', () => { true ]) await expect(connection.close()).resolves.toBe(true) - expect(connection.exitVerdict).toEqual({ root: 'processless', tree: 'exited' }) + expect(connection.exitVerdict).toEqual({ root: 'exited', tree: 'exited', processless: true }) } ) diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index 887076b3a537..eba09ce78747 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -16,7 +16,7 @@ import { } from './claude-agent-sdk-control-requests' import { createClaudeChildTreeReaper, proveClaudeChildExit } from './claude-agent-sdk-exit-proof' import { withTimeout } from '../../shared/promise-timeout-fallback' -import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' +import type { ProviderProcessVerdict } from '../provider-process/provider-process-close' import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' import { claudeUnwrittenUserMessageError, @@ -83,8 +83,9 @@ export type ClaudeStreamJsonConnectionHandlers = { * is never collapsed into either neighbour. */ export type ClaudeChildExitVerdict = { - root: 'exited' | 'live' | 'processless' - tree: DescendantTreeVerdict + root: ProviderProcessVerdict + tree: ProviderProcessVerdict + processless?: boolean } export type ClaudeStreamJsonConnection = ClaudeControlSurface & { @@ -168,7 +169,7 @@ export async function openClaudeStreamJsonConnection( let readingBarrier: Promise | null = null let releaseReadingBarrier: (() => void) | null = null const pauseReading = (): void => { - if (closing || managed.exited || terminalError || readingBarrier) { + if (closing || managed.rootVerdict === 'exited' || terminalError || readingBarrier) { return } readingBarrier = new Promise((resolve) => { @@ -205,8 +206,6 @@ export async function openClaudeStreamJsonConnection( armTreeOnOutput() } - const exitPromise = managed.exitPromise - const handleUnexpectedEnd = (cause?: Error): void => { resumeReading() terminalError ??= exitError(spawner.stderrTail, exitStatus, cause) @@ -215,7 +214,7 @@ export async function openClaudeStreamJsonConnection( faultReported = true handlers.onFault?.(terminalError) } - if (managed.exited && !exitReported) { + if (managed.rootVerdict === 'exited' && !managed.processless && !exitReported) { exitReported = true handlers.onExit?.(terminalError, { expected: closing }) } @@ -247,7 +246,7 @@ export async function openClaudeStreamJsonConnection( } catch (error: unknown) { // The SDK ends its generator in error when the child dies or the transport // fails; a transport failure with a live child still has to reap the tree. - if (!closing && !managed.exited) { + if (!closing && managed.rootVerdict !== 'exited') { void tree.reap() } handleUnexpectedEnd(error instanceof Error ? error : new Error(String(error))) @@ -260,7 +259,7 @@ export async function openClaudeStreamJsonConnection( handleUnexpectedEnd() }) child.on('error', (error) => { - if (!closing && !managed.exited) { + if (!closing && managed.rootVerdict !== 'exited') { void tree.reap() } handleUnexpectedEnd(error) @@ -286,7 +285,7 @@ export async function openClaudeStreamJsonConnection( const send: ClaudeStreamJsonConnection['send'] = (message, beforeDispatch) => { if ( closing || - managed.exited || + managed.rootVerdict === 'exited' || terminalError || child.stdin.destroyed || !child.stdin.writable @@ -310,16 +309,12 @@ export async function openClaudeStreamJsonConnection( await (tree.refresh?.() ?? tree.capture()) inbox.end() const proven = await proveClaudeChildExit({ - child, - exitPromise, - exited: rootSettled, tree, - supervised: spawner.supervised, managed }) inbox.fail(new Error('claude stream-json connection closed')) if (!proven) { - if (managed.exited && tree.treeVerdict === 'live') { + if (managed.lastCloseResult?.root === 'exited' && managed.lastCloseResult.tree === 'live') { console.warn('[claude-stream-json] root exited but a descendant survived the close:', { pid: spawner.pid }) @@ -348,12 +343,13 @@ export async function openClaudeStreamJsonConnection( return spawner.pid }, get closed() { - return closing || managed.exited || terminalError !== null + return closing || managed.rootVerdict === 'exited' || terminalError !== null }, get exitVerdict() { return { - root: managed.processless ? 'processless' : managed.exited ? 'exited' : 'live', - tree: tree.treeVerdict + root: managed.rootVerdict, + tree: tree.treeVerdict, + ...(managed.processless ? { processless: true } : {}) } as const }, pauseReading, diff --git a/src/main/claude/claude-structured-session-acquisition-processless.test.ts b/src/main/claude/claude-structured-session-acquisition-processless.test.ts index eb4babdb9aca..4279f2ea9862 100644 --- a/src/main/claude/claude-structured-session-acquisition-processless.test.ts +++ b/src/main/claude/claude-structured-session-acquisition-processless.test.ts @@ -29,7 +29,7 @@ describe('Claude structured processless acquisition', () => { const connection: ClaudeStreamJsonConnection = { pid: undefined, closed: true, - exitVerdict: { root: 'processless', tree: 'exited' }, + exitVerdict: { root: 'exited', tree: 'exited', processless: true }, initializationResult: async () => { throw fault }, diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 903f49f052e7..f2fa78a009b6 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -34,7 +34,7 @@ export function claudeAcquisitionCleanupError( cause: unknown ): Error { const verdict = connection?.exitVerdict - if (verdict?.root === 'processless') { + if (verdict?.processless === true) { return new AgentSessionPreSpawnError(cause) } return claudeRootExitObserved(connection) @@ -57,7 +57,7 @@ export async function resolveClaudeAcquisitionError(input: { prompt.settle(null) } const closed = (await input.attempt.connection?.close()) ?? true - if (input.attempt.connection?.exitVerdict.root === 'processless') { + if (input.attempt.connection?.exitVerdict.processless === true) { acquisitionError = new AgentSessionPreSpawnError(input.error) } else if (!closed) { acquisitionError = claudeAcquisitionCleanupError(input.attempt.connection, input.error) diff --git a/src/main/claude/claude-supervised-stop.integration.test.ts b/src/main/claude/claude-supervised-stop.integration.test.ts index 6f0749e79e79..c3dcb0087496 100644 --- a/src/main/claude/claude-supervised-stop.integration.test.ts +++ b/src/main/claude/claude-supervised-stop.integration.test.ts @@ -140,22 +140,16 @@ async function spawnClaude(env: Record = {}) { const options = sdkOptions(env) const child = spawner.spawn(options) recordedPids.add(child.pid!) - let exited = false + const managed = spawner.managed + if (!managed) { + throw new Error('Claude spawner did not retain its managed child') + } const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => - child.once('exit', (code, signal) => { - exited = true - resolve({ code, signal }) - }) + managed.onExit(resolve) ) const pids = await readPids(child, ['claude', 'tool', 'daemon']) - const close = (): Promise => - proveClaudeChildExit({ - child, - exitPromise: exit.then(() => undefined), - exited: () => exited, - supervised: spawner.supervised - }) - return { child, exit, pids, close, marker: String(options.env.ORCA_TEST_SIGTERM_MARKER) } + const close = (): Promise => proveClaudeChildExit({ managed }) + return { child, managed, exit, pids, close, marker: String(options.env.ORCA_TEST_SIGTERM_MARKER) } } afterEach(() => { @@ -172,31 +166,37 @@ afterEach(() => { describe.runIf(process.platform !== 'win32')('Claude under the POSIX provider supervisor', () => { it('stops a mid-turn Claude on close instead of letting stdin end finish its turn', async () => { - const { child, exit, pids, close, marker } = await spawnClaude() + const { child, managed, exit, pids, close, marker } = await spawnClaude() expect(child.pid).not.toBe(pids.claude) const startedAt = Date.now() await expect(close()).resolves.toBe(true) + expect(managed.lastCloseResult).toEqual({ root: 'exited', tree: 'exited' }) + await expect(close()).resolves.toBe(true) // Claude's own SIGTERM reap ran at once, not after the supervisor's stdin-end grace. expect(Date.now() - startedAt).toBeLessThan(PROVIDER_STDIN_END_GRACE_MS) expect(existsSync(marker)).toBe(true) - await expect(exit).resolves.toEqual({ code: null, signal: 'SIGTERM' }) + await expect(exit).resolves.toEqual({ code: null, signal: 'SIGTERM', processless: false }) expect(alive(pids.claude)).toBe(false) expect(alive(pids.tool)).toBe(false) }) it('lets the supervisor escalate a Claude that ignores SIGTERM, and exits only after it', async () => { - const { exit, pids, close } = await spawnClaude({ ORCA_TEST_CLAUDE_IGNORES_SIGTERM: '1' }) + const { managed, exit, pids, close } = await spawnClaude({ + ORCA_TEST_CLAUDE_IGNORES_SIGTERM: '1' + }) const startedAt = Date.now() await expect(close()).resolves.toBe(true) + expect(managed.lastCloseResult).toEqual({ root: 'exited', tree: 'exited' }) + await expect(close()).resolves.toBe(true) const elapsed = Date.now() - startedAt expect(elapsed).toBeGreaterThanOrEqual(PROVIDER_SIGTERM_GRACE_MS) expect(elapsed).toBeLessThan(PROVIDER_SUPERVISOR_MAX_STOP_MS + 1_000) // The supervisor's own SIGTERM stop finished the job; nothing forced the supervisor itself. - await expect(exit).resolves.toEqual({ code: null, signal: 'SIGTERM' }) + await expect(exit).resolves.toEqual({ code: null, signal: 'SIGTERM', processless: false }) expect(alive(pids.claude)).toBe(false) }) diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index 7442f7d69d6f..a0c4b7ba9faf 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -391,6 +391,7 @@ describe('openCodexAppServerConnection', () => { child.stderr.write('codex crashed\n') await flushStreams() child.emit('exit', 1, null) + child.emit('exit', 1, null) child.emit('close', 1, null) expect((await inFlight).message).toContain('codex crashed') @@ -431,6 +432,7 @@ describe('openCodexAppServerConnection', () => { expect(error.name).toBe('CodexAppServerHandshakeExitUnprovenError') expect(error.connection).toBeDefined() + child.emit('exit', 1, null) child.emit('close', 1, null) await expect(error.connection?.close()).resolves.toBe(true) }) diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 961390aa5d58..9e8466dbacda 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -50,7 +50,7 @@ export async function openCodexAppServerConnection( const managed = spawnManagedProviderProcess(launch, { spawnImpl, site: 'codex-app-server-teardown', - closeEventIsExit: true, + acceptClose: (result) => result.root === 'exited', policy: (supervised) => ({ gracefulExitMs: supervised ? PROVIDER_SUPERVISOR_MAX_STOP_MS : GRACEFUL_EXIT_MS, forcedExitMs: FORCED_EXIT_MS @@ -146,7 +146,7 @@ export async function openCodexAppServerConnection( } function notify(method: string, params?: Record): void { - if (managed.exited || managed.processless || terminalError) { + if (managed.rootVerdict === 'exited' || terminalError) { return } try { @@ -192,8 +192,7 @@ export async function openCodexAppServerConnection( function writeResponse(payload: Record): void { if ( - managed.exited || - managed.processless || + managed.rootVerdict === 'exited' || terminalError || child.stdin.destroyed || !child.stdin.writable @@ -212,9 +211,9 @@ export async function openCodexAppServerConnection( return Promise.resolve(true) } closing = true - return managed.close().then((verdict) => { + return managed.close().then((result) => { dispatcher.failPending(new Error('codex app-server connection closed')) - return verdict === 'exited' + return result.root === 'exited' }) } @@ -226,7 +225,10 @@ export async function openCodexAppServerConnection( return closing || managed.rootVerdict === 'exited' || terminalError !== null }, get processTreeUnproven() { - return managed.teardownUnproven + return ( + managed.lastCloseResult?.root === 'exited' && + managed.lastCloseResult.teardownAccepted === false + ) }, request, notify, diff --git a/src/main/provider-process/managed-provider-process-grace.test.ts b/src/main/provider-process/managed-provider-process-grace.test.ts new file mode 100644 index 000000000000..de61b9505cd0 --- /dev/null +++ b/src/main/provider-process/managed-provider-process-grace.test.ts @@ -0,0 +1,73 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../shared/child-process/run-process' +import { spawnManagedProviderProcess } from './managed-provider-process' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from './provider-process-supervisor' + +function fixture() { + const child = Object.assign(new EventEmitter(), { + pid: 9_999_999, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(() => true) + }) + const spawn = vi.fn(() => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies all process fields the managed lifecycle consumes. + return child as unknown as ReturnType + }) + return { child, spawn } +} + +describe('managed provider supervisor grace', () => { + it.each([false, true])( + 'rejects a short grace before spawning (signal on close: %s)', + (signalSupervisorOnClose) => { + const { spawn } = fixture() + expect(() => + spawnManagedProviderProcess( + { command: 'fixture-provider', args: [] }, + { + spawnImpl: spawn, + platform: 'linux', + site: 'fixture', + policy: () => ({ + gracefulExitMs: PROVIDER_SUPERVISOR_MAX_STOP_MS - 1, + forcedExitMs: 50, + signalSupervisorOnClose + }), + acceptClose: (result) => result.root === 'exited' + } + ) + ).toThrow( + `Supervised provider graceful exit must wait at least ${PROVIDER_SUPERVISOR_MAX_STOP_MS} ms` + ) + expect(spawn).not.toHaveBeenCalled() + } + ) + + it.each(['linux', 'win32'] as const)( + 'accepts the floor on POSIX and the shorter Windows grace (%s)', + (platform) => { + const { child, spawn } = fixture() + const managed = spawnManagedProviderProcess( + { command: 'fixture-provider', args: [] }, + { + spawnImpl: spawn, + platform, + site: 'fixture', + policy: (supervised) => ({ + gracefulExitMs: supervised ? PROVIDER_SUPERVISOR_MAX_STOP_MS : 100, + forcedExitMs: 50 + }), + acceptClose: (result) => result.root === 'exited' + } + ) + expect(spawn).toHaveBeenCalledOnce() + expect(managed.rootVerdict).toBe('live') + child.emit('exit', 0, null) + expect(managed.rootVerdict).toBe('exited') + } + ) +}) diff --git a/src/main/provider-process/managed-provider-process.test.ts b/src/main/provider-process/managed-provider-process.test.ts index de1435711912..a6f4c04fb772 100644 --- a/src/main/provider-process/managed-provider-process.test.ts +++ b/src/main/provider-process/managed-provider-process.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events' import { PassThrough } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' import type { spawnProcess } from '../../shared/child-process/run-process' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from './provider-process-supervisor' import { spawnManagedProviderProcess } from './managed-provider-process' import type { ProviderProcessTree, ProviderProcessVerdict } from './provider-process-close' @@ -32,13 +33,14 @@ function fakeChild(pid: number | null = 9_999_999) { return { child, spawn } } -function launch(fixture: ReturnType, platform: NodeJS.Platform = 'darwin') { +function launch(fixture: ReturnType, platform: NodeJS.Platform = 'win32') { return spawnManagedProviderProcess( { command: 'fixture-provider', args: ['serve'], cwd: '/workspace' }, { spawnImpl: fixture.spawn, platform, site: 'fixture-provider-teardown', + acceptClose: (result) => result.root === 'exited', policy: () => ({ gracefulExitMs: 100, forcedExitMs: 50 }) } ) @@ -77,7 +79,8 @@ describe('managed provider process', () => { platform: 'darwin', inheritedEnv: { SECRET: 'inherited', PATH: '/bin' }, site: 'fixture', - policy: () => ({ gracefulExitMs: 100, forcedExitMs: 50 }) + acceptClose: (result) => result.root === 'exited' && result.tree === 'exited', + policy: () => ({ gracefulExitMs: PROVIDER_SUPERVISOR_MAX_STOP_MS, forcedExitMs: 50 }) } ) expect(managed.supervised).toBe(true) @@ -88,7 +91,7 @@ describe('managed provider process', () => { }) ) expect(fixture.spawn.mock.calls[0][0].env).not.toHaveProperty('SECRET') - expect(managed.rootVerdict).toBe('unverifiable') + expect(managed.rootVerdict).toBe('live') }) it('observes exit once across exit and close, including a late subscriber', async () => { @@ -108,7 +111,7 @@ describe('managed provider process', () => { const late = vi.fn() managed.onExit(late) expect(late).toHaveBeenCalledExactlyOnceWith({ code: 7, signal: 'SIGTERM', processless: false }) - await expect(managed.close()).resolves.toBe('exited') + await expect(managed.close()).resolves.toMatchObject({ root: 'exited' }) expect(fixture.child.kill).not.toHaveBeenCalled() }) @@ -120,28 +123,28 @@ describe('managed provider process', () => { const closing = managed.close() expect(fixture.child.stdin.writableEnded).toBe(true) await vi.advanceTimersByTimeAsync(0) - await expect(closing).resolves.toBe('exited') + await expect(closing).resolves.toMatchObject({ root: 'exited' }) expect(fixture.child.kill).not.toHaveBeenCalled() expect(vi.getTimerCount()).toBe(0) }) - it('joins a close, returns unverifiable after the kill deadline, and retries on the same child', async () => { + it('joins a close, keeps root live after the kill deadline, and retries on the same child', async () => { vi.useFakeTimers() const fixture = fakeChild() const managed = launch(fixture) const first = managed.close() expect(managed.close()).toBe(first) await vi.advanceTimersByTimeAsync(150) - await expect(first).resolves.toBe('unverifiable') + await expect(first).resolves.toMatchObject({ root: 'live' }) expect(fixture.child.kill).toHaveBeenCalledWith('SIGKILL') - expect(managed.exited).toBe(false) + expect(managed.rootVerdict).toBe('live') fixture.child.kill.mockImplementation(() => { fixture.child.emit('exit', null, 'SIGKILL') return true }) const second = managed.close() await vi.advanceTimersByTimeAsync(150) - await expect(second).resolves.toBe('exited') + await expect(second).resolves.toMatchObject({ root: 'exited' }) expect(fixture.spawn).toHaveBeenCalledOnce() expect(vi.getTimerCount()).toBe(0) }) @@ -154,22 +157,25 @@ describe('managed provider process', () => { managed.onExit(report) const close = managed.close() await vi.advanceTimersByTimeAsync(150) - await expect(close).resolves.toBe('unverifiable') + await expect(close).resolves.toMatchObject({ root: 'live' }) fixture.child.emit('exit', 0, null) fixture.child.emit('close', 0, null) expect(report).toHaveBeenCalledOnce() - await expect(managed.close()).resolves.toBe('exited') + await expect(managed.close()).resolves.toMatchObject({ root: 'exited' }) }) it('requires error then close for a processless spawn to settle', async () => { const fixture = fakeChild(null) const managed = launch(fixture) + expect(managed.rootVerdict).toBe('unverifiable') + fixture.child.emit('close', -2, null) + expect(managed.rootVerdict).toBe('unverifiable') fixture.child.emit('error', new Error('ENOENT')) expect(managed.rootVerdict).toBe('unverifiable') fixture.child.emit('close', null, null) expect(managed.processless).toBe(true) - expect(managed.exited).toBe(false) - await expect(managed.close()).resolves.toBe('exited') + expect(managed.rootVerdict).toBe('exited') + await expect(managed.close()).resolves.toMatchObject({ root: 'exited' }) }) it('does not turn a transport close with an existing pid into Claude exit proof', () => { @@ -177,7 +183,7 @@ describe('managed provider process', () => { const managed = launch(fixture) fixture.child.emit('error', new Error('EPIPE')) fixture.child.emit('close', 0, null) - expect(managed.exited).toBe(false) + expect(managed.rootVerdict).toBe('live') expect(managed.processless).toBe(false) }) @@ -188,13 +194,13 @@ describe('managed provider process', () => { expect(managed.supervised).toBe(false) const first = managed.close() await vi.advanceTimersByTimeAsync(150) - await expect(first).resolves.toBe('unverifiable') + await expect(first).resolves.toMatchObject({ root: 'live' }) expect(mocks.windowsTree).toHaveBeenCalledWith(fixture.child.pid, { site: 'fixture-provider-teardown' }) expect(fixture.child.kill).not.toHaveBeenCalledWith('SIGTERM') fixture.child.emit('exit', null, 'SIGKILL') - await expect(managed.close()).resolves.toBe('exited') + await expect(managed.close()).resolves.toMatchObject({ root: 'exited' }) }) it('preserves a caller requiring tree proof, including live and unverifiable descendants', async () => { @@ -205,16 +211,22 @@ describe('managed provider process', () => { spawnImpl: fixture.spawn, platform: 'darwin', site: 'fixture', - policy: () => ({ gracefulExitMs: 100, forcedExitMs: 50, requireTreeExit: true }) + acceptClose: (result) => result.root === 'exited' && result.tree === 'exited', + policy: () => ({ gracefulExitMs: PROVIDER_SUPERVISOR_MAX_STOP_MS, forcedExitMs: 50 }) } ) const proof = fakeTree() fixture.child.emit('exit', 0, null) - await expect(managed.close(proof.tree)).resolves.toBe('unverifiable') + await expect(managed.close(proof.tree)).resolves.toEqual({ + root: 'exited', + tree: 'unverifiable' + }) proof.setVerdict('live') - await expect(managed.close(proof.tree)).resolves.toBe('live') + await expect(managed.close(proof.tree)).resolves.toEqual({ root: 'exited', tree: 'live' }) proof.setVerdict('exited') - await expect(managed.close(proof.tree)).resolves.toBe('exited') + await expect(managed.close(proof.tree)).resolves.toEqual({ root: 'exited', tree: 'exited' }) + await expect(managed.close(proof.tree)).resolves.toEqual({ root: 'exited', tree: 'exited' }) + expect(proof.tree.capture).toHaveBeenCalledTimes(3) }) it('reports failed tree cleanup separately after observed root exit', async () => { @@ -227,8 +239,12 @@ describe('managed provider process', () => { }) const close = managed.close() await vi.advanceTimersByTimeAsync(150) - await expect(close).resolves.toBe('exited') - expect(managed.teardownUnproven).toBe(true) + await expect(close).resolves.toMatchObject({ root: 'exited' }) + expect(managed.lastCloseResult).toEqual({ + root: 'exited', + tree: 'unverifiable', + teardownAccepted: false + }) }) it('keeps the cleanup diagnostic tied to the close that finished before a late root exit', async () => { @@ -238,10 +254,14 @@ describe('managed provider process', () => { mocks.windowsTree.mockRejectedValueOnce(new Error('tree teardown unavailable')) const close = managed.close() await vi.advanceTimersByTimeAsync(150) - await expect(close).resolves.toBe('unverifiable') + await expect(close).resolves.toMatchObject({ root: 'live' }) fixture.child.emit('exit', null, 'SIGKILL') - await expect(managed.close()).resolves.toBe('exited') - expect(managed.teardownUnproven).toBe(false) + await expect(managed.close()).resolves.toMatchObject({ root: 'exited' }) + expect(managed.lastCloseResult).toEqual({ + root: 'live', + tree: 'unverifiable', + teardownAccepted: false + }) }) it('lets a supervised caller signal immediately and waits its configured grace before forcing', async () => { @@ -253,7 +273,12 @@ describe('managed provider process', () => { spawnImpl: fixture.spawn, platform: 'darwin', site: 'fixture', - policy: () => ({ gracefulExitMs: 600, forcedExitMs: 50, signalSupervisorOnClose: true }) + acceptClose: (result) => result.root === 'exited' && result.tree === 'exited', + policy: () => ({ + gracefulExitMs: PROVIDER_SUPERVISOR_MAX_STOP_MS + 500, + forcedExitMs: 50, + signalSupervisorOnClose: true + }) } ) fixture.child.kill.mockImplementation(() => { @@ -263,9 +288,9 @@ describe('managed provider process', () => { const close = managed.close() await vi.advanceTimersByTimeAsync(549) expect(fixture.child.kill).toHaveBeenCalledExactlyOnceWith('SIGTERM') - expect(managed.exited).toBe(false) + expect(managed.rootVerdict).toBe('live') await vi.advanceTimersByTimeAsync(1) - await expect(close).resolves.toBe('exited') + await expect(close).resolves.toMatchObject({ root: 'exited' }) expect(fixture.child.kill).not.toHaveBeenCalledWith('SIGKILL') expect(vi.getTimerCount()).toBe(0) }) diff --git a/src/main/provider-process/managed-provider-process.ts b/src/main/provider-process/managed-provider-process.ts index a3c37182c88e..c80696c8cc7f 100644 --- a/src/main/provider-process/managed-provider-process.ts +++ b/src/main/provider-process/managed-provider-process.ts @@ -1,11 +1,15 @@ import { spawnProcess } from '../../shared/child-process/run-process' import { RetryableProcessExitProof } from '../../shared/child-process/retryable-process-exit-proof' import type { ProviderProcessLaunch } from './provider-process-launch' -import { createProviderSpawnSpec } from './provider-process-supervisor' +import { + PROVIDER_SUPERVISOR_MAX_STOP_MS, + createProviderSpawnSpec +} from './provider-process-supervisor' import { terminateProviderProcessTree } from './provider-process-teardown' import { closeProviderProcess, type ProviderProcessClosePolicy, + type ProviderProcessCloseResult, type ProviderProcessTree, type ProviderProcessVerdict } from './provider-process-close' @@ -22,22 +26,19 @@ type ManagedProviderProcessOptions = { spawnImpl?: typeof spawnProcess platform?: NodeJS.Platform inheritedEnv?: NodeJS.ProcessEnv - /** Preserve callers which accept Node's close event as root exit evidence. */ - closeEventIsExit?: boolean + acceptClose: (result: ProviderProcessCloseResult) => boolean } export type ManagedProviderProcess = { child: ReturnType supervised: boolean - readonly exited: boolean readonly processless: boolean readonly rootVerdict: ProviderProcessVerdict - /** Cleanup failed in a close that observed the root exit. */ - readonly teardownUnproven: boolean + readonly lastCloseResult: ProviderProcessCloseResult | null readonly exitPromise: Promise onExit(listener: (exit: ProviderProcessExit) => void): void terminateTree(): Promise - close(tree?: ProviderProcessTree): Promise + close(tree?: ProviderProcessTree): Promise } /** One child owns its exit observation and every retry of an unconfirmed close. */ @@ -47,6 +48,12 @@ export function spawnManagedProviderProcess( ): ManagedProviderProcess { const platform = options.platform ?? process.platform const spec = createProviderSpawnSpec(launch, options.inheritedEnv ?? process.env, platform) + const policy = options.policy(spec.supervised) + if (spec.supervised && !(policy.gracefulExitMs >= PROVIDER_SUPERVISOR_MAX_STOP_MS)) { + throw new RangeError( + `Supervised provider graceful exit must wait at least ${PROVIDER_SUPERVISOR_MAX_STOP_MS} ms; received ${policy.gracefulExitMs} ms` + ) + } const child = (options.spawnImpl ?? spawnProcess)({ program: spec.program, args: spec.args, @@ -55,14 +62,11 @@ export function spawnManagedProviderProcess( detached: spec.detached, stdio: ['pipe', 'pipe', 'pipe'] }) - const policy = options.policy(spec.supervised) const listeners = new Set<(exit: ProviderProcessExit) => void>() let observed: ProviderProcessExit | null = null let spawnFailed = false - let teardownUnproven = false - const exitProof = new RetryableProcessExitProof( - (verdict) => verdict === 'exited' - ) + let lastCloseResult: ProviderProcessCloseResult | null = null + const exitProof = new RetryableProcessExitProof(options.acceptClose) let resolveExit = (): void => {} const exitPromise = new Promise((resolve) => { resolveExit = resolve @@ -84,11 +88,12 @@ export function spawnManagedProviderProcess( }) child.on('close', (code, signal) => { const processless = spawnFailed && child.pid === undefined - if (processless || options.closeEventIsExit) { + if (processless) { observeExit({ code, signal, processless }) } }) - const exited = (): boolean => observed !== null + const rootVerdict = (): ProviderProcessVerdict => + observed ? 'exited' : child.pid === undefined ? 'unverifiable' : 'live' const terminateTree = (): Promise => terminateProviderProcessTree(child, { site: options.site, platform }) @@ -96,17 +101,14 @@ export function spawnManagedProviderProcess( child, supervised: spec.supervised, exitPromise, - get exited() { - return observed !== null && !observed.processless - }, get processless() { return observed?.processless ?? false }, get rootVerdict() { - return observed ? 'exited' : 'unverifiable' + return rootVerdict() }, - get teardownUnproven() { - return teardownUnproven && observed !== null + get lastCloseResult() { + return lastCloseResult }, onExit(listener) { if (observed) { @@ -117,23 +119,21 @@ export function spawnManagedProviderProcess( }, terminateTree, close(tree) { - if (observed && !policy.requireTreeExit) { - return Promise.resolve('exited') + if (observed && !tree) { + return Promise.resolve({ root: 'exited', tree: 'unverifiable' }) } return exitProof.run(async () => { const result = await closeProviderProcess({ child, exitPromise, - exited, + rootVerdict, supervised: spec.supervised, policy, tree, terminateTree }) - if (result.teardownAccepted !== undefined) { - teardownUnproven = !result.teardownAccepted && result.verdict === 'exited' - } - return result.verdict + lastCloseResult = result + return result }) } } diff --git a/src/main/provider-process/provider-process-close.ts b/src/main/provider-process/provider-process-close.ts index 333d6af16e1d..9077929d17a9 100644 --- a/src/main/provider-process/provider-process-close.ts +++ b/src/main/provider-process/provider-process-close.ts @@ -11,17 +11,15 @@ export type ProviderProcessTree = { } export type ProviderProcessClosePolicy = { - /** Must cover the supervisor's own stop bound when supervising a real child. */ gracefulExitMs: number forcedExitMs: number signalSupervisorOnClose?: boolean - requireTreeExit?: boolean } export type ProviderProcessCloseInput = { child: Pick exitPromise: Promise - exited: () => boolean + rootVerdict: () => ProviderProcessVerdict supervised?: boolean policy: ProviderProcessClosePolicy tree?: ProviderProcessTree @@ -29,7 +27,8 @@ export type ProviderProcessCloseInput = { } export type ProviderProcessCloseResult = { - verdict: ProviderProcessVerdict + root: ProviderProcessVerdict + tree: ProviderProcessVerdict /** Acceptance of the fallback teardown is separate from observed process exit. */ teardownAccepted?: boolean } @@ -47,14 +46,14 @@ export async function closeProviderProcess( } catch { // A broken pipe still owes the reap. } - if (input.supervised && policy.signalSupervisorOnClose && !input.exited()) { + if (input.supervised && policy.signalSupervisorOnClose && input.rootVerdict() !== 'exited') { child.kill('SIGTERM') } let reaped = false let teardownAccepted: boolean | undefined - if (!input.exited()) { + if (input.rootVerdict() !== 'exited') { await waitForProcessExitUntil(input.exitPromise, policy.gracefulExitMs) - if (!input.exited()) { + if (input.rootVerdict() !== 'exited') { reaped = true await tree?.refresh?.() if (tree) { @@ -65,13 +64,12 @@ export async function closeProviderProcess( await waitForProcessExitUntil(input.exitPromise, policy.forcedExitMs) } } - if (!reaped && input.exited() && tree && tree.treeVerdict !== 'exited') { + if (!reaped && input.rootVerdict() === 'exited' && tree && tree.treeVerdict !== 'exited') { await tree.reap() } - const verdict = !input.exited() - ? 'unverifiable' - : policy.requireTreeExit - ? (tree?.treeVerdict ?? 'unverifiable') - : 'exited' - return { verdict, ...(teardownAccepted === undefined ? {} : { teardownAccepted }) } + return { + root: input.rootVerdict(), + tree: tree?.treeVerdict ?? 'unverifiable', + ...(teardownAccepted === undefined ? {} : { teardownAccepted }) + } } diff --git a/src/shared/child-process/retryable-process-exit-proof.test.ts b/src/shared/child-process/retryable-process-exit-proof.test.ts index a7bc57976452..bda5a23589ba 100644 --- a/src/shared/child-process/retryable-process-exit-proof.test.ts +++ b/src/shared/child-process/retryable-process-exit-proof.test.ts @@ -4,7 +4,7 @@ import { RetryableProcessExitProof } from './retryable-process-exit-proof' describe('RetryableProcessExitProof', () => { it('shares a concurrent attempt and retains proven exit', async () => { - const proof = new RetryableProcessExitProof() + const proof = new RetryableProcessExitProof((result: boolean) => result) const proveExit = vi.fn(async () => true) const first = proof.run(proveExit) @@ -16,7 +16,7 @@ describe('RetryableProcessExitProof', () => { }) it('permits another attempt after exit was not proven', async () => { - const proof = new RetryableProcessExitProof() + const proof = new RetryableProcessExitProof((result: boolean) => result) const proveExit = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) await expect(proof.run(proveExit)).resolves.toBe(false) @@ -25,7 +25,7 @@ describe('RetryableProcessExitProof', () => { }) it('permits another attempt after proof rejects', async () => { - const proof = new RetryableProcessExitProof() + const proof = new RetryableProcessExitProof((result: boolean) => result) const proveExit = vi .fn() .mockRejectedValueOnce(new Error('probe failed')) diff --git a/src/shared/child-process/retryable-process-exit-proof.ts b/src/shared/child-process/retryable-process-exit-proof.ts index 117f05fa6cd1..675a038f9cf9 100644 --- a/src/shared/child-process/retryable-process-exit-proof.ts +++ b/src/shared/child-process/retryable-process-exit-proof.ts @@ -1,9 +1,7 @@ -export class RetryableProcessExitProof { +export class RetryableProcessExitProof { private inFlight: Promise | null = null - constructor( - private readonly isProven: (result: Result) => boolean = (result) => result === true - ) {} + constructor(private readonly isProven: (result: Result) => boolean) {} run(proveExit: () => Promise): Promise { if (this.inFlight) { From 1cbdf7e88d33d9be77114e2a072834232f431a33 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:10:18 -0700 Subject: [PATCH 084/269] feat(acp): registered agents reach agent.launch and find their account home on the runtime agent.launch's preflight and host probe accept any agent this host registered, instead of naming Claude and Codex. A registration may bring its own account-home resolver (Grok: GROK_HOME, else ~/.grok on the runtime's own machine), so a new agent adds no branch to the runtime. The Agent Permissions setting and the per-agent environment reach ACP launches; saved model and effort picks are re-applied when a child starts, and refused ones are reported. --- src/main/acp/acp-structured-acquire.ts | 4 +- .../acp-structured-adapter.test-support.ts | 11 +- .../acp-structured-launch-resolution.test.ts | 124 ++++++++++++++++++ src/main/acp/acp-structured-options.ts | 38 ++++++ .../acp-structured-session-adapter.test.ts | 7 +- .../acp/acp-structured-session-adapter.ts | 3 + src/main/acp/acp-structured-session.ts | 2 + .../agent-launch-executor.test.ts | 13 +- .../agent-launch/agent-launch-executor.ts | 6 +- src/main/agent-launch/agent-launch-mode.ts | 9 +- .../agent-launch-surface-factories.ts | 4 +- ...structured-agent-session-create-support.ts | 8 +- .../runtime/orca-runtime-get-worktree-ps.ts | 17 ++- .../runtime/structured-agent-account-home.ts | 16 +++ ...ed-agent-runtime-registrations-acp.test.ts | 49 +++++++ .../structured-agent-runtime-registrations.ts | 20 +++ src/shared/structured-agent-session-create.ts | 8 +- .../structured-agent-session-mutation.ts | 4 +- 18 files changed, 319 insertions(+), 24 deletions(-) create mode 100644 src/main/acp/acp-structured-launch-resolution.test.ts create mode 100644 src/main/runtime/structured-agent-runtime-registrations-acp.test.ts diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 26b87439e6ab..935b173dcf41 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -24,7 +24,7 @@ import type { AcpStructuredChild } from './acp-structured-child' import { ACP_HANDLE_TRANSPORT } from './acp-structured-agent-definitions' import { AcpStructuredLane } from './acp-structured-lane' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' -import { AcpStructuredOptions } from './acp-structured-options' +import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options' import { AcpStructuredPrompts } from './acp-structured-prompts' import { routeAcpSessionEvent, type AcpStructuredSession } from './acp-structured-session' import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' @@ -190,6 +190,7 @@ export async function acquireAcpStructuredSession(input: { } options.adoptSession(started.response) liveLane.apply(liveLane.translator.contextModels(started.response.models, now())) + const restoreSkipped = await restoreAcpSessionOptions(runtime, options, acquire.options) const process = await identity.read(child.pid) const link: AgentSessionProviderHandleLink = { linkId: @@ -217,6 +218,7 @@ export async function acquireAcpStructuredSession(input: { now, settle: input.onSettled }), + restoreSkipped, closeRequested: false, ended: false, exitObservedAt: null diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index 5a0ffcf07491..d8b5dad39eb1 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -99,9 +99,10 @@ export type AcpAdapterRig = { spawned: string[] lifecycle: StructuredAgentSessionLifecycleEvent[] settled: Parameters>[0][] - acquire(options?: { fence?: number; onSpawned?: () => Promise }): ReturnType< - AcpStructuredSessionAdapter['acquire'] - > + acquire(options?: { + fence?: number + onSpawned?: () => Promise + }): ReturnType /** Frames Orca wrote to the agent with this method. */ sent(method: string): FakeFrame[] /** Waits for the `index`th frame Orca writes with this method. */ @@ -148,7 +149,9 @@ export async function openAcpAdapterRig( }) const opened = { sessionId: PROVIDER_SESSION, configOptions: GROK_CONFIG_OPTIONS } agent.on('session/new', (frame) => agent.reply(frame, opened)) - agent.on('session/load', (frame) => agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS })) + agent.on('session/load', (frame) => + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + ) options.script?.(agent) return child }, diff --git a/src/main/acp/acp-structured-launch-resolution.test.ts b/src/main/acp/acp-structured-launch-resolution.test.ts new file mode 100644 index 000000000000..220a3fb7ffd2 --- /dev/null +++ b/src/main/acp/acp-structured-launch-resolution.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor' +import { ACP_CHILD_ENV_TO_DELETE, acpLaunchSpecFor } from './acp-launch-specs' +import { createAcpStructuredLaunchResolver } from './acp-structured-launch-resolution' + +const GROK = acpLaunchSpecFor('grok')! +const identity = { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: null +} + +function grokRecord(overrides: Partial = {}): AgentSessionRecord { + return { + ...agentSessionRecordFixture(), + provider: 'grok', + providerHandleChain: [], + accountHome: { variable: 'GROK_HOME', path: '/home/user/.grok-work' }, + ...overrides + } +} + +function resolver(record: AgentSessionRecord, fullAccess = false) { + const searched: (string | null | undefined)[] = [] + return { + searched, + resolve: createAcpStructuredLaunchResolver(GROK, { + store: { getRecord: () => record }, + resolveWorkspacePath: async () => '/repo/worktree', + resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), + resolveLaunchEnv: () => ({ GROK_EXTRA: '1' }), + resolveFullAccess: () => fullAccess, + resolveCommand: (command, options) => { + searched.push(options?.pathEnv) + return `/resolved/${command}` + } + }) + } +} + +describe('ACP launch resolution', () => { + it('pins the record account home, never self-updates, and searches the agent install dir', async () => { + const { resolve, searched } = resolver(grokRecord()) + const launch = await resolve({ identity }) + expect(launch).toMatchObject({ + command: '/resolved/grok', + args: ['--no-auto-update', 'agent', '--no-leader', 'stdio'], + cwd: '/repo/worktree', + fullAccess: false, + resume: null + }) + expect(launch.env).toMatchObject({ + GROK_HOME: '/home/user/.grok-work', + GROK_DISABLE_AUTOUPDATER: '1', + GROK_EXTRA: '1' + }) + expect(searched[0]).toContain('/home/user/.grok-work/bin') + }) + + it('asks the agent to approve everything only under full access', async () => { + const launch = await resolver(grokRecord(), true).resolve({ identity }) + expect(launch.args).toContain('--always-approve') + }) + + it('resumes the chain head, replaceable only when this chat created it', async () => { + const link = (origin: 'created' | 'resumed') => ({ + linkId: `link-${origin}`, + origin, + mintedAtFence: 1, + observedAt: 1, + handle: { transport: 'acp', agent: 'grok', nativeId: 'acp-1' } + }) + const created = await resolver(grokRecord({ providerHandleChain: [link('created')] })).resolve({ + identity + }) + expect(created.resume).toMatchObject({ sessionId: 'acp-1' }) + expect(created.resume?.replaceableKey).toEqual(expect.any(String)) + const resumed = await resolver( + grokRecord({ providerHandleChain: [link('created'), link('resumed')] }) + ).resolve({ identity }) + expect(resumed.resume).toEqual({ sessionId: 'acp-1', replaceableKey: null }) + }) + + it('refuses a record pinned to another host or another agent', async () => { + const remote = grokRecord() + remote.location = { ...remote.location, executionHostId: 'ssh:box' } + await expect(resolver(remote).resolve({ identity })).rejects.toThrow(/run on this runtime/) + await expect(resolver(agentSessionRecordFixture()).resolve({ identity })).rejects.toThrow( + /claude session/ + ) + }) +}) + +describe('Grok status: the structured session is the only producer', () => { + it('strips every pane identity and hook endpoint an inherited environment carries', () => { + const inherited = { + PATH: '/usr/bin', + ORCA_PANE_KEY: 'tab-1:pane-1', + ORCA_AGENT_PANE: 'tab-1:pane-1', + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: 'wt-1', + ORCA_AGENT_LAUNCH_TOKEN: 'launch-1', + ORCA_AGENT_HOOK_PORT: '4321', + ORCA_AGENT_HOOK_TOKEN: 'secret', + ORCA_AGENT_HOOK_ENDPOINT: '/tmp/endpoint' + } + const spec = createProviderSpawnSpec( + { + command: 'grok', + args: ['agent', 'stdio'], + env: { GROK_HOME: '/home/user/.grok' }, + envToDelete: ACP_CHILD_ENV_TO_DELETE + }, + inherited, + 'win32' + ) + expect(Object.keys(spec.env).filter((key) => key.startsWith('ORCA_'))).toEqual([]) + expect(spec.env).toMatchObject({ PATH: '/usr/bin', GROK_HOME: '/home/user/.grok' }) + }) +}) diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index 49721f3e2276..f91561589e76 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -8,6 +8,9 @@ import type { AgentSessionOptionsResult, AgentSessionSlashCommand } from '../../shared/agent-session-wire' +import { isAcpStructuredOptionKey } from './acp-structured-agent-definitions' +import { AcpRpcError } from './acp-errors' +import type { AcpSessionRuntime } from './acp-session-runtime' import { SessionConfigSelectGroupSchema, SessionConfigSelectOptionSchema, @@ -154,3 +157,38 @@ export class AcpStructuredOptions { return option && isSelect(option) ? option : null } } + +/** Re-applies the chat's saved picks to the agent's new session; a pick it refuses is skipped and + * reported, never retried. */ +export async function restoreAcpSessionOptions( + runtime: Pick, + options: AcpStructuredOptions, + saved: Readonly> | undefined +): Promise { + const skipped: string[] = [] + for (const [key, value] of Object.entries(saved ?? {})) { + const reported = options.reported() + if (!isAcpStructuredOptionKey(key) || reported[key] === value) { + continue + } + const write = options.write(key, value) + try { + if (write?.method === 'config') { + options.adoptConfigOptions( + (await runtime.setConfigOption(write.configId, value)).configOptions + ) + } else if (write?.method === 'model') { + await runtime.setModel(write.modelId) + options.adoptModel(write.modelId) + } else { + skipped.push(key) + } + } catch (error) { + if (!(error instanceof AcpRpcError)) { + throw error + } + skipped.push(key) + } + } + return skipped +} diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index b10cf9f25d8e..2a2dafb079dc 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -317,7 +317,12 @@ describe('ACP structured session adapter: close and exit', () => { describe('Grok launch spec', () => { it('never self-updates and asks for always-approve only with full access', () => { - expect(GROK.args({ fullAccess: false })).toEqual(['--no-auto-update', 'agent', '--no-leader', 'stdio']) + expect(GROK.args({ fullAccess: false })).toEqual([ + '--no-auto-update', + 'agent', + '--no-leader', + 'stdio' + ]) expect(GROK.args({ fullAccess: true })).toEqual([ '--no-auto-update', 'agent', diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 3048824e1021..ab894cfe5d70 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -151,6 +151,9 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte readOptions = async (input: { sessionId: string; fence: number }) => this.live(input.sessionId).options.read() + readOptionRestoreFailures = (sessionId: string): readonly string[] => + this.sessions.get(sessionId)?.restoreSkipped ?? [] + readCommands = (sessionId: string) => this.sessions.get(sessionId)?.options.readCommands() holdsDispatch = (sessionId: string): boolean => diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts index 9388f9a64a7b..d60c091bfa17 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -23,6 +23,8 @@ export type AcpStructuredSession = { prompts: AcpStructuredPrompts options: AcpStructuredOptions turns: AcpStructuredTurns + /** Saved picks the agent refused when this child started. */ + restoreSkipped: readonly string[] /** Orca asked this child to stop; its exit is then a requested close. */ closeRequested: boolean ended: boolean diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index e7270287fb53..8f938b8aa561 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -244,10 +244,21 @@ describe('a launch into a workspace that already exists', () => { describe('an agent with no structured session', () => { it('stays a terminal without asking the host', async () => { const h = harness({}) - const result = await h.run({ agent: 'grok', target: { kind: 'existing', worktree: 'wt-7' } }) + const result = await h.run({ agent: 'gemini', target: { kind: 'existing', worktree: 'wt-7' } }) expect(h.calls).toEqual(['createTerminalAgent']) expect(result.receipt).toMatchObject({ reason: 'agent_without_structured_session' }) }) + + it('asks the host for an agent it registered beyond Claude and Codex', async () => { + const h = harness({}) + const result = await h.run({ ...CREATE_INTENT, agent: 'grok' }) + expect(h.calls).toEqual([ + 'createWorktree(startupAgent=undefined)', + 'createSupport', + 'createStructuredSession' + ]) + expect(result.receipt).toMatchObject({ mode: 'structured' }) + }) }) describe('the prompt receipt', () => { diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 1d9bae4f45d3..1dbe646cdb34 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -240,7 +240,7 @@ async function createSurface( settled: AgentLaunchModeReceipt ): Promise { const { intent, surfaces } = execution - if (settled.mode === 'structured' && isStructuredProvider(intent.agent)) { + if (settled.mode === 'structured') { // One reservation serves either route: the tab half of the reserved pane is the chat's tab. const reservedTabId = intent.paneKey ? parsePaneKey(intent.paneKey)?.tabId : undefined const session = await surfaces.createStructuredSession({ @@ -344,10 +344,6 @@ function combineLaunchWarnings( return `${create} Also ${surface[0].toLowerCase()}${surface.slice(1)}` } -function isStructuredProvider(agent: TuiAgent): agent is 'claude' | 'codex' { - return agent === 'claude' || agent === 'codex' -} - function existingWorktreeId(target: AgentLaunchTarget): string { return target.kind === 'existing' ? target.worktree : '' } diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 0ef01c01da06..4f69e0728844 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -25,6 +25,7 @@ import type { } from '../../shared/agent-launch-intent' import type { GlobalSettings } from '../../shared/global-settings-types' import { RUNTIME_CAPABILITIES } from '../../shared/protocol-version' +import { STRUCTURED_AGENT_STORAGE } from '../runtime/structured-agent-runtime-registrations' import { prefersStructuredNativeChatByDefault, resolveStructuredNativeChatSupport, @@ -83,6 +84,8 @@ export type AgentLaunchModePlacement = { workspacePath?: string } +const REGISTERED_STRUCTURED_AGENTS: readonly string[] = [...STRUCTURED_AGENT_STORAGE.keys()] + const DOWNGRADE_DETAIL: Record, string> = { remote_execution_host: 'this launch runs on a remote execution host', reused_terminal: 'it reuses a running terminal agent', @@ -144,13 +147,15 @@ export function decideAgentLaunchMode(args: { if (placement.on) { return downgraded('remote_execution_host', vocabulary) } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an unrecognized agent name is handled rather than trusted; isAgentSessionHandleProvider rejects it and the launch downgrades to a terminal. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an unrecognized agent name is handled rather than trusted; the registered-agent check rejects it and the launch downgrades to a terminal. const agent = placement.agent as TuiAgent const support = resolveStructuredNativeChatSupport({ agent, executionHostId: 'local', reusesTerminal: Boolean(placement.terminal), hostCapabilities: RUNTIME_CAPABILITIES, + // This host is the one that will run the agent, so its own registrations answer. + hostStructuredAgents: REGISTERED_STRUCTURED_AGENTS, // The floating workspace has nowhere to keep a session, so it is decided here rather than left // to the host probe below, which cannot answer for a workspace with no record. WSL still is: // the create-support probe reads the resolved workspace rather than guessing from a @@ -201,7 +206,7 @@ async function readStructuredCreateSupport( worktreeId: string, agent: TuiAgent | undefined ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' } | null> { - if (agent !== 'claude' && agent !== 'codex') { + if (!agent || !REGISTERED_STRUCTURED_AGENTS.includes(agent)) { return { supported: false, reason: 'agent' } } try { diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index 867eac40ab61..98c741fbdb43 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -5,6 +5,7 @@ */ import type { AgentLaunchPrompt } from '../../shared/agent-launch-intent' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' import type { TuiAgent } from '../../shared/tui-agent' /** How a surface is built once the executor has decided which one. Injected because an @@ -13,7 +14,8 @@ import type { TuiAgent } from '../../shared/tui-agent' export type AgentLaunchSurfaceFactory = { createStructuredSession(args: { worktreeId: string - agent: 'claude' | 'codex' + /** An agent this host registered as structured; the launch mode already checked it. */ + agent: StructuredAgentId options?: Readonly> /** The caller-minted session id; refused with `AgentLaunchSessionAlreadyExistsError` if taken. */ sessionId?: string diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index d3bf0e191343..845ff6cbb198 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -20,6 +20,7 @@ import { import { resolveStructuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' import { ensureStructuredAgentSessionHostUnlessRefused } from './structured-agent-session-host-refusal' +import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { parseWslUncPath } from '../../shared/wsl-paths' import { parseWorkspaceKey } from '../../shared/workspace-scope' @@ -64,7 +65,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR } /** Where a launch of `agent` finds its account, resolved on this host; null for an agent with no - * resolver here, whose create is refused. Claude and Codex are the agents with one today. */ + * resolver here, whose create is refused. Every other registered agent brings its own. */ protected structuredAgentAccountHomePathResolver( agent: StructuredAgentId, worktree: string, @@ -100,7 +101,10 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR }) } } - return null + const resolveAccountHomePath = structuredAgentRuntimeRegistration(agent)?.resolveAccountHomePath + return resolveAccountHomePath + ? async ({ launchEnv }) => resolveAccountHomePath({ launchEnv }) + : null } /** The definition the installed host registered for `agent`: what its account home pins. */ diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 4333cc7e3a5e..70caf7e0b983 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -21,7 +21,11 @@ import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { buildWorktreeListingPage } from './worktree-listing-host-scope' import { structuredWorkerOwesWork } from './structured-worker-custody' -import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import { + resolvedTuiAgentArgsBypassPermissions, + resolveTuiAgentLaunchEnv +} from '../../shared/tui-agent-launch-defaults' +import { isTuiAgent } from '../../shared/tui-agent-config' import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' import { claudeStructuredPermissionModeForSettings } from '../claude/claude-structured-permission-mode' import { codexStructuredPermissionPolicyForSettings } from '../codex/codex-structured-permission-policy' @@ -164,6 +168,17 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis claudeStructuredPermissionModeForSettings(this.requireStore().getSettings()), resolveCodexPermissionPolicy: () => codexStructuredPermissionPolicyForSettings(this.requireStore().getSettings()), + resolveAgentFullAccess: (agent) => + isTuiAgent(agent) && + resolvedTuiAgentArgsBypassPermissions( + agent, + this.requireStore().getSettings(), + process.platform + ), + resolveAgentLaunchEnv: (agent) => + isTuiAgent(agent) + ? resolveTuiAgentLaunchEnv(agent, this.requireStore().getSettings().agentDefaultEnv) + : {}, // Same gate and same settings as agentSession.createSupport, re-read on every acquisition. getClaudeManagedAccountGateSettings: () => this.requireStore().getSettings(), resolveAgentAccountHome: (agent) => this.resolveStructuredAgentAccountHome(agent), diff --git a/src/main/runtime/structured-agent-account-home.ts b/src/main/runtime/structured-agent-account-home.ts index 060fbea5b1fd..cfac35e84de6 100644 --- a/src/main/runtime/structured-agent-account-home.ts +++ b/src/main/runtime/structured-agent-account-home.ts @@ -1,6 +1,7 @@ import { homedir } from 'node:os' import { join } from 'node:path' import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { resolveAbsoluteDirOverride } from '../../shared/absolute-dir-override' // The one resolver for "which account home would a structured launch pin right // now". The create path fills `record.accountHome` with it, and the model @@ -54,3 +55,18 @@ export async function resolveStructuredCodexAccountHomePath( getSystemCodexHomePath() ) } + +/** An agent whose config directory is one environment variable with a default under the user's + * home: the launch's own value, then this runtime's, then the default. */ +export function resolveStructuredEnvAccountHomePath(input: { + launchEnv: NodeJS.ProcessEnv + variable: string + defaultPath: (homePath: string) => string + processEnv?: NodeJS.ProcessEnv + homePath?: string +}): string { + return resolveAbsoluteDirOverride( + input.launchEnv[input.variable] ?? (input.processEnv ?? process.env)[input.variable], + input.defaultPath(input.homePath ?? homedir()) + ) +} diff --git a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts new file mode 100644 index 000000000000..92ca538ee236 --- /dev/null +++ b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + STRUCTURED_AGENT_STORAGE, + structuredAgentRuntimeRegistration +} from './structured-agent-runtime-registrations' + +describe('ACP agents in the runtime registrations', () => { + it('registers Grok beside Claude and Codex with its declared capabilities', () => { + expect( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) + ).toEqual(['codex', 'claude', 'grok']) + expect(structuredAgentRuntimeRegistration('grok')?.definition).toMatchObject({ + handleTransport: 'acp', + accountHomeVariable: 'GROK_HOME', + capabilities: { + rewind: false, + compact: false, + threadGoal: false, + contextUsage: true, + imagePrompts: false, + steering: 'queue', + approvalEnforcement: 'orca' + } + }) + }) + + it('admits Grok records under the protocol transport and its config home variable', () => { + expect(STRUCTURED_AGENT_STORAGE.get('grok')).toEqual({ + agent: 'grok', + handleTransport: 'acp', + accountHomeVariable: 'GROK_HOME' + }) + }) + + it('takes model and effort picks at rest, and keeps no model list of its own', () => { + const resting = structuredAgentRuntimeRegistration('grok')!.definition.restingOptions + expect(['model', 'effort'].map(resting.acceptsKey)).toEqual([true, true]) + expect(resting.acceptsKey('fastMode')).toBe(false) + expect(resting.fallbackModels()).toBeNull() + }) + + it('finds the account home on this runtime from the launch env, else the default', () => { + const resolve = structuredAgentRuntimeRegistration('grok')!.resolveAccountHomePath! + expect(resolve({ launchEnv: { GROK_HOME: '/data/grok' } })).toBe('/data/grok') + expect(resolve({ launchEnv: { GROK_HOME: 'relative/grok' } })).toMatch(/\.grok$/) + expect(structuredAgentRuntimeRegistration('claude')?.resolveAccountHomePath).toBeUndefined() + }) +}) diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index be57032df051..4c4a383d5e0d 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -24,6 +24,7 @@ import type { createStructuredAgentSessionDispatchFollowUps } from './structured import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' import type { createStructuredAgentEnvironmentResolvers } from './structured-agent-shell-environment' import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' +import { resolveStructuredEnvAccountHomePath } from './structured-agent-account-home' import { ACP_LAUNCH_SPECS, type AcpLaunchSpec } from '../acp/acp-launch-specs' import { acpStructuredAgentDefinition } from '../acp/acp-structured-agent-definitions' import { spawnAcpStructuredChild } from '../acp/acp-structured-child' @@ -51,6 +52,9 @@ export type StructuredAgentRuntimeAdapter = StructuredAgentSessionAdapter & { export type StructuredAgentRuntimeRegistration = { definition: StructuredAgentDefinition createAdapter: (context: StructuredAgentAdapterContext) => StructuredAgentRuntimeAdapter + /** Where a new chat of this agent finds its account on this runtime's machine. Absent: the + * runtime resolves it itself (Claude and Codex, which need its account services). */ + resolveAccountHomePath?: (input: { launchEnv: NodeJS.ProcessEnv }) => string } function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredAgentRuntimeAdapter { @@ -117,6 +121,12 @@ function createClaudeAdapter( function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistration { return { definition: acpStructuredAgentDefinition(spec), + resolveAccountHomePath: ({ launchEnv }) => + resolveStructuredEnvAccountHomePath({ + launchEnv, + variable: spec.accountHomeVariable, + defaultPath: spec.defaultAccountHome + }), createAdapter: (context) => { const { deps, store, followUps } = context return new AcpStructuredSessionAdapter({ @@ -150,6 +160,16 @@ export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRun ...ACP_LAUNCH_SPECS.map(acpRegistration) ] +export function structuredAgentRuntimeRegistration( + agent: string +): StructuredAgentRuntimeRegistration | null { + return ( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.find( + (registration) => registration.definition.agent === agent + ) ?? null + ) +} + /** What the record store admits: exactly the registered agents' declared storage. */ export const STRUCTURED_AGENT_STORAGE: AgentSessionStoredAgents = agentSessionStoredAgents( STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map((registration) => registration.definition) diff --git a/src/shared/structured-agent-session-create.ts b/src/shared/structured-agent-session-create.ts index 28d5fe8d7663..1ca1dd7f6274 100644 --- a/src/shared/structured-agent-session-create.ts +++ b/src/shared/structured-agent-session-create.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from './agent-session-provider-handle' +import type { StructuredAgentId } from './agent-session-provider-handle' import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { createStructuredAgentSessionOperationId, @@ -21,7 +21,7 @@ export type StructuredAgentSessionResumeSource = { export type StructuredAgentSessionCreateParams = { envelope: AgentSessionMutationEnvelope worktree: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId resumeFrom?: StructuredAgentSessionResumeSource /** Sent only to a host advertising `AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY`. */ tabId?: string @@ -30,7 +30,7 @@ export type StructuredAgentSessionCreateParams = { /** Provider-prefixed so a session id names its lane on sight, and underscore-only * so the id stays a single token everywhere it is embedded (tab ids, log keys). */ export function createStructuredAgentSessionId( - agent: AgentSessionHandleProvider, + agent: StructuredAgentId, randomUuid: () => string ): string { return `${agent}_${randomUuid().replaceAll('-', '_')}` @@ -52,7 +52,7 @@ export function isStructuredAgentSessionIdFor(agent: string, sessionId: string): export function structuredAgentSessionCreateParams(args: { sessionId: string worktree: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId resumeFrom?: StructuredAgentSessionResumeSource tabId?: string randomUuid: () => string diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index 16f6f4f45831..d075af5d28bc 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -1,7 +1,7 @@ import { sha256 } from './sha256' import { isAgentSessionHandleProvider, - type AgentSessionHandleProvider + type StructuredAgentId } from './agent-session-provider-handle' function canonicalize(value: unknown): string { @@ -45,7 +45,7 @@ export function structuredAgentSessionDomainFingerprint(input: { export function structuredAgentSessionCreateFingerprint(input: { sessionId: string worktree: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId resumeFrom?: { providerSessionId: string } tabId?: string }): string { From c96dbe6f3320830068184eee829e4769cda2c9f1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:10:22 -0700 Subject: [PATCH 085/269] chore(acp): drop an unused import --- src/main/agent-launch/agent-launch-executor.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 1dbe646cdb34..7dfe6038f309 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -40,7 +40,6 @@ import { promptReceipt, settleLaunchPromptDisposal } from './agent-launch-prompt-delivery' -import type { TuiAgent } from '../../shared/tui-agent' import { workspaceKindForWorktreeId, type WorkspaceLaunchKind From ad947c5820637f696f6d2c3682c6c85cb605bddd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:12:19 -0700 Subject: [PATCH 086/269] test(acp): Grok's recorded sessions and launch rules through the real adapter Recorded basic, permission and cancel sessions play back as the agent behind the adapter, and the journal a client reads is checked: one completed turn with its tools, an allowed then a rejected approval, and a Stop that leaves the running tool interrupted. Launch resolution, registration and the status single-producer environment are covered beside them. --- ...-structured-fixture-replay.test-support.ts | 102 +++++++++++++++ ...uctured-session-adapter-recordings.test.ts | 122 ++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 src/main/acp/acp-structured-fixture-replay.test-support.ts create mode 100644 src/main/acp/acp-structured-session-adapter-recordings.test.ts diff --git a/src/main/acp/acp-structured-fixture-replay.test-support.ts b/src/main/acp/acp-structured-fixture-replay.test-support.ts new file mode 100644 index 000000000000..c4cf1605c797 --- /dev/null +++ b/src/main/acp/acp-structured-fixture-replay.test-support.ts @@ -0,0 +1,102 @@ +// Plays a recorded Grok session back as the agent behind the real adapter. Orca's own frames +// advance the recording: the agent's lines after each one are sent until the recording waits on +// Orca again. The recording's prompt ids and session id are rewritten to the ones this run uses, +// as the real agent echoes the prompt id it was sent. + +import { expect } from 'vitest' +import { z } from 'zod' +import type { AcpFixtureFrame } from './acp-timeline-fixture.test-support' +import type { AcpScriptedAgent, FakeFrame } from './acp-scripted-agent.test-support' +import { PROVIDER_SESSION } from './acp-structured-adapter.test-support' + +/** Recordings name their sessions `session-N`; a replay plays them all as this run's one. */ +const RECORDED_SESSION = /"session-\d+"/g +const promptMetaSchema = z.object({ _meta: z.object({ promptId: z.string() }) }) +const messageSchema = z.object({ + id: z.union([z.string(), z.number()]).optional(), + method: z.string().optional(), + params: z.unknown().optional(), + result: z.unknown().optional() +}) + +function promptIdOf(params: unknown): string | undefined { + return promptMetaSchema.safeParse(params).data?._meta.promptId +} + +export class GrokFixtureReplay { + private cursor = 0 + private agent: AcpScriptedAgent | null = null + private readonly promptIds = new Map() + private readonly requestIds = new Map() + + constructor(private readonly frames: AcpFixtureFrame[]) {} + + attach(agent: AcpScriptedAgent): void { + this.agent = agent + agent.on('session/prompt', (frame) => this.orcaSent('session/prompt', frame)) + agent.on('session/cancel', (frame) => this.orcaSent('session/cancel', frame)) + } + + /** What the recording waits for Orca to send next; null once it has played to the end. */ + get awaiting(): string | null { + const next = this.frames[this.cursor] + if (!next) { + return null + } + return next.direction === 'out' ? (next.message.method ?? 'reply') : 'agent' + } + + private orcaSent(method: string, frame: FakeFrame): void { + const recorded = this.frames[this.cursor] + expect(recorded?.direction).toBe('out') + expect(recorded?.message.method).toBe(method) + if (method === 'session/prompt' && recorded) { + const recordedId = promptIdOf(recorded.message.params) + const sentId = promptIdOf(frame.params) + if (recordedId && sentId) { + this.promptIds.set(recordedId, sentId) + } + this.requestIds.set(recorded.message.id ?? '', frame.id) + } + this.cursor += 1 + this.play() + } + + private play(): void { + const agent = this.agent + while (agent && this.frames[this.cursor]?.direction === 'in') { + const recorded = this.frames[this.cursor]! + this.cursor += 1 + const message = this.rewrite(recorded.message) + if (message.method !== undefined && message.id !== undefined) { + // The agent asks Orca something; the recording resumes once Orca answers it. + void agent.request(message.id, message.method, message.params).then(() => { + expect(this.frames[this.cursor]?.direction).toBe('out') + this.cursor += 1 + this.play() + }) + return + } + if (message.method !== undefined) { + agent.notify(message.method, message.params) + } else { + agent.send({ + jsonrpc: '2.0', + id: this.requestIds.get(message.id ?? '') ?? message.id, + result: message.result + }) + } + } + } + + private rewrite(message: AcpFixtureFrame['message']): AcpFixtureFrame['message'] { + let text = JSON.stringify(message).replaceAll( + RECORDED_SESSION, + JSON.stringify(PROVIDER_SESSION) + ) + for (const [recorded, sent] of this.promptIds) { + text = text.replaceAll(JSON.stringify(recorded), JSON.stringify(sent)) + } + return messageSchema.parse(JSON.parse(text)) + } +} diff --git a/src/main/acp/acp-structured-session-adapter-recordings.test.ts b/src/main/acp/acp-structured-session-adapter-recordings.test.ts new file mode 100644 index 000000000000..8b29e3fbb220 --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-recordings.test.ts @@ -0,0 +1,122 @@ +// The adapter against Grok's recorded traffic: each recording plays back as the agent, and the +// assertions read the journal a client would see. + +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + openAcpAdapterRig, + waitFor, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' +import { GrokFixtureReplay } from './acp-structured-fixture-replay.test-support' +import { readAcpFixture } from './acp-timeline-fixture.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const ask: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Fixture prompt' }] +} + +async function replaying(name: string): Promise<{ rig: AcpAdapterRig; replay: GrokFixtureReplay }> { + const replay = new GrokFixtureReplay(await readAcpFixture(name)) + const rig = await openAcpAdapterRig({ script: (agent) => replay.attach(agent) }) + await rig.acquire() + return { rig, replay } +} + +async function turns(rig: AcpAdapterRig) { + return (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) +} + +async function rowsOf(rig: AcpAdapterRig, kind: string) { + return (await rig.rig.rows()).filter((row) => row.body.kind === kind) +} + +function send(rig: AcpAdapterRig, clientMessageId: string) { + return rig.adapter.dispatch({ sessionId: SESSION, clientMessageId, body: ask, fence: 1 }) +} + +describe('Grok recordings through the adapter', () => { + it('s1: a read-only turn lands as one completed turn with its tools and reply', async () => { + const { rig, replay } = await replaying('s1-basic') + await send(rig, 'send-1') + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([{ state: 'completed', outcome: 'success' }]) + ) + const tools = await rowsOf(rig, 'tool-call') + expect(tools.length).toBeGreaterThan(0) + expect( + tools.every((row) => row.body.kind === 'tool-call' && row.body.state === 'completed') + ).toBe(true) + expect( + (await rig.rig.rows()).some( + (row) => row.body.kind === 'message' && row.body.role === 'assistant' + ) + ).toBe(true) + expect(rig.settled).toMatchObject([{ clientMessageId: 'send-1', providerIdentity: {} }]) + }) + + it('s2: an allowed and then a rejected permission are each answered once, after the commit', async () => { + const { rig, replay } = await replaying('s2-permission') + const answer = async (optionId: string, index: number) => { + const approval = await waitFor(async () => { + const found = (await rowsOf(rig, 'approval'))[index] + if (!found || replay.awaiting !== 'reply') { + throw new Error('no pending approval yet') + } + return found + }) + await rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: approval.itemId, + kind: 'approval', + response: { kind: 'option', optionId }, + fence: 1, + commit: async () => {} + }) + } + await send(rig, 'send-1') + await answer('allow-once', 0) + await waitFor(async () => expect(await turns(rig)).toHaveLength(1)) + await waitFor(() => expect(replay.awaiting).toBe('session/prompt')) + await send(rig, 'send-2') + await answer('reject-once', 1) + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([ + { state: 'completed', outcome: 'success' }, + { state: 'interrupted', outcome: 'cancellation' } + ]) + ) + }) + + it('s3: a Stop mid-tool ends the turn interrupted and the session takes the next prompt', async () => { + const { rig, replay } = await replaying('s3-cancel') + await send(rig, 'send-1') + await waitFor(() => expect(replay.awaiting).toBe('session/cancel')) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([{ state: 'interrupted', outcome: 'cancellation' }]) + ) + // The recording left the shell call `in_progress`; the turn's end cuts it short. + const tool = (await rowsOf(rig, 'tool-call')).at(-1) + expect(tool?.body).toMatchObject({ kind: 'tool-call', state: 'failed', endedAs: 'interrupted' }) + await send(rig, 'send-2') + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect((await turns(rig)).at(-1)).toMatchObject({ state: 'completed', outcome: 'success' }) + ) + }) +}) From 0f56af212c7a068e64692013db55235a219e3a16 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:12:41 -0700 Subject: [PATCH 087/269] feat(native-chat): offer and render chats of any agent the host registered The desktop reads each host's `agentSession.agents` (local and every paired runtime that advertises registered agents) into one cache keyed by host and runtime instance, and passes the listed ids to the structured launch decision. It now advertises `agent-session.structured.registered-agents.v1` on both the local renderer path and to paired hosts, since it renders those chats. The launch chain, provisional tab, launch persistence, tab bar pending state, pane overlay, worktree-creation flow and terminal seeding gate no longer assume Claude or Codex: the structured route, decided against the host's list, is the one gate. Adopting a conversation from the AI vault stays Claude/Codex (only they have transcript importers). On the chat surface, image input follows the agent's `imagePrompts` record (an image is referenced by path when the agent takes none), and an agent without a shipped structured seed starts with an empty picker filled only from what its session reports. --- ...ktop-renderer-runtime-capabilities.test.ts | 14 +- .../desktop-renderer-runtime-capabilities.ts | 3 + .../app-shell/use-app-startup-hydration.ts | 2 + .../native-chat-resume-on-restart-grouping.ts | 5 +- .../native-chat/NativeChatComposer.tsx | 1 + .../NativeChatStructuredSession.tsx | 4 + ...StructuredAgentSessionPaneOverlayLayer.tsx | 9 +- ...tructuredAgentSessionStatusBridge.test.tsx | 19 +- .../native-chat/native-chat-composer-types.ts | 2 + ...red-agent-registered-agent-surface.test.ts | 67 ++++++++ .../structured-agent-session-seed-catalog.ts | 21 +++ .../structured-agent-session-tabs.ts | 5 +- .../use-host-model-catalog-upgrade.ts | 4 +- .../use-native-chat-composer-attachments.ts | 8 +- ...-native-chat-composer-image-input.test.tsx | 96 +++++++++++ ...e-native-chat-resolved-path-attachments.ts | 10 +- ...e-structured-agent-session-option-state.ts | 8 +- .../use-structured-agent-session-options.ts | 4 +- .../components/tab-bar/QuickLaunchButton.tsx | 13 +- .../TabBarCreateEntry.keyboard.test.tsx | 3 +- .../components/tab-bar/TabBarCreateEntry.tsx | 12 +- .../src/components/tab-bar/TabBarItemRow.tsx | 7 +- ...nitial-terminal-structured-launch.test.tsx | 4 +- .../use-terminal-watcher-effects.ts | 9 +- .../src/lib/agent-launch-route-input.ts | 12 +- ...ent-launch-route-registered-agents.test.ts | 134 +++++++++++++++ src/renderer/src/lib/agent-launch-routing.ts | 7 +- .../src/lib/agent-session-launch-plan.test.ts | 21 ++- .../src/lib/agent-session-launch-plan.ts | 4 +- .../lib/launch-structured-agent-session.ts | 12 +- .../lib/structured-agent-launch-settlement.ts | 6 +- ...structured-agent-session-host-admission.ts | 6 +- .../structured-agent-session-launch-draft.ts | 4 +- ...d-agent-session-launch-persistence.test.ts | 22 +++ ...ctured-agent-session-launch-persistence.ts | 10 +- ...nt-session-launch-registered-agent.test.ts | 69 ++++++++ ...tructured-agent-session-launch-registry.ts | 6 +- .../structured-agent-session-launch-status.ts | 42 ++++- .../lib/structured-agent-session-launch.ts | 11 +- ...ructured-agent-session-paired-admission.ts | 11 +- ...tructured-agent-session-provisional-tab.ts | 21 +-- .../src/lib/worktree-creation-flow-execute.ts | 7 +- .../worktree-creation-structured-session.ts | 4 +- .../runtime/host-structured-agents-sync.ts | 33 ++++ .../runtime/host-structured-agents.test.ts | 162 ++++++++++++++++++ .../src/runtime/host-structured-agents.ts | 145 ++++++++++++++++ .../src/runtime/use-host-structured-agent.ts | 50 ++++++ ...tron-remote-runtime-client-capabilities.ts | 3 + src/shared/structured-agent-session-create.ts | 8 +- .../structured-agent-session-mutation.ts | 4 +- 50 files changed, 1009 insertions(+), 135 deletions(-) create mode 100644 src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-seed-catalog.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx create mode 100644 src/renderer/src/lib/agent-launch-route-registered-agents.test.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-registered-agent.test.ts create mode 100644 src/renderer/src/runtime/host-structured-agents-sync.ts create mode 100644 src/renderer/src/runtime/host-structured-agents.test.ts create mode 100644 src/renderer/src/runtime/host-structured-agents.ts create mode 100644 src/renderer/src/runtime/use-host-structured-agent.ts diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 3d7f236917d5..541ec9828621 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -26,7 +26,10 @@ import { import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { remoteRuntimeClientCapabilities } from '../../shared/remote-runtime-client-capabilities' import { supportsAgentLaunch } from '../runtime/rpc/methods/agent-launch' -import { createSupportFollowsHostSetting } from '../runtime/rpc/methods/structured-agent-session-policy' +import { + createSupportFollowsHostSetting, + structuredAgentsReadBy +} from '../runtime/rpc/methods/structured-agent-session-policy' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' // Every paired transport sends the shared base plus the Electron list, so compare the union. @@ -100,6 +103,15 @@ describe('desktop renderer runtime client capabilities', () => { ) }) + // The renderer reads `agentSession.agents` (host-structured-agents.ts) and renders any listed + // agent's chat, so each host serves it every agent's tabs. + it.each([ + ['a paired host', PAIRED_HOST_RECEIVES], + ['its own main process', DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] + ] as const)('reads every agent %s registered', (_host, clientCapabilities) => { + expect(structuredAgentsReadBy({ clientKind: 'runtime', clientCapabilities })).toBeUndefined() + }) + it('diverges from what a paired host receives only where a decision was recorded', () => { expect(missingFrom(PAIRED_HOST_RECEIVES, DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES)).toEqual( [...REMOTE_ONLY_BY_DECISION].sort() diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index ba4b6b254728..13144b922df8 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -7,6 +7,7 @@ import { AGENT_SESSION_TURN_ITEM_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' @@ -34,6 +35,8 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + // The renderer reads `agentSession.agents` and renders a chat tab of any agent its host lists. + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, // Without this `supportsAgentLaunch` refuses the renderer outright, while the same renderer // targeting a remote host is admitted — the asymmetry this constant exists to close. diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 4186481e7fa9..37a64f4b5632 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import { restoreLocalStructuredChatsAtStartup } from '@/runtime/local-structured-chats' import { syncZoomCSSVar } from '@/lib/ui-zoom' import { installCodexDetachedPaneRestartExecutor } from '@/components/terminal-pane/codex-detached-pane-restart-scheduler' +import { installHostStructuredAgentsSync } from '@/runtime/host-structured-agents-sync' import { useAppStore } from '../store' import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-workspace-tab-models' import { useStartupActions } from './use-app-startup-actions' @@ -65,6 +66,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta }, [onOnboardingLoaded]) useEffect(() => installCodexDetachedPaneRestartExecutor(), []) + useEffect(() => installHostStructuredAgentsSync(), []) // Fetch initial data + hydrate GitHub cache from disk useEffect(() => { diff --git a/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts b/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts index 225ad6621ac6..1d365187c6c4 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts @@ -4,6 +4,7 @@ import type { ExecutionHostId } from '../../../shared/execution-host' import { projectGroupIdFromRepoId } from '../../../shared/folder-workspace-worktree' import type { RepoIcon } from '../../../shared/repo-icon' import type { AgentSessionRestartActivity } from '../../../shared/agent-session-restart-activity' +import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' /** * The offered chats, arranged the way the sidebar arranges workspaces: project/repo, then workspace, @@ -16,7 +17,9 @@ import type { AgentSessionRestartActivity } from '../../../shared/agent-session- export type ResumeCandidate = { sessionId: string workspaceId: string - agent: 'claude' | 'codex' + /** Any agent the host registered; a client without the registered-agents capability gets + * only Claude's and Codex's offers. */ + agent: StructuredAgentId trigger: 'quit' | 'update' latestPrompt: string recordedAt: number diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d9fb9f1ec2d7..e5d661847099 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -156,6 +156,7 @@ const NativeChatComposerPane = forwardRef { const threadGoal = controller.threadGoal const setThreadGoalObjective = threadGoal @@ -234,6 +236,7 @@ export function NativeChatStructuredSession( optionSnapshot: controller.optionSnapshot, optionPickerRequest, sessionCommands: controller.sessionCommands, + acceptsImages, contextUsage: controller.contextUsage, worktreeId: fileLinkContext?.worktreeId, onError: setComposerError, @@ -243,6 +246,7 @@ export function NativeChatStructuredSession( props.target.kind === 'local' ? null : (props.target.environmentId ?? null) } }, [ + acceptsImages, controller, fileLinkContext?.worktreeId, optionPickerRequest, diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx index 376705942afb..942a6a5a5200 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx @@ -1,7 +1,6 @@ import { memo, useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import type { Tab, TabGroup } from '../../../../shared/tab-types' -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import { useAppStore } from '@/store' import { structuredAgentSessionOwnerForTab, @@ -9,6 +8,7 @@ import { } from '@/runtime/structured-agent-session-owner' import { RetainedPaneHost } from '../tab-group/RetainedPaneHost' import NativeChatView from './NativeChatView' +import { isStructuredTab } from './structured-agent-session-tabs' type StructuredAgentSessionTab = Tab & { contentType: 'agent-session' @@ -84,12 +84,7 @@ const StructuredAgentSessionPaneOverlayLayer = memo( [groups] ) const structuredTabs = useMemo( - () => - unifiedTabs.filter( - (tab): tab is StructuredAgentSessionTab => - tab.contentType === 'agent-session' && - isAgentSessionHandleProvider(tab.agentSessionAgent) - ), + () => unifiedTabs.filter((tab): tab is StructuredAgentSessionTab => isStructuredTab(tab)), [unifiedTabs] ) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index 1c60187910c2..f7234f8bc70d 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -701,10 +701,25 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(feed().target).toEqual({ kind: 'environment', environmentId: 'server-1' }) }) - it('does not project an unknown provider as Codex', async () => { + // Hosts publish chat tabs only of agents they registered; each projects as itself. + it("projects a host-registered agent's status as that agent, never as Codex", async () => { mocks.store?.setState({ unifiedTabsByWorktree: { - 'wt-1': [{ ...structuredTab, agentSessionAgent: 'gemini' }] + 'wt-1': [{ ...structuredTab, agentSessionAgent: 'grok' }] + } + }) + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + + act(() => feed().emit({ type: 'snapshot', sessions: [summary()] })) + + expect(statuses()).toEqual([expect.objectContaining({ agentType: 'grok' })]) + }) + + it('does not project a tab naming no agent', async () => { + mocks.store?.setState({ + unifiedTabsByWorktree: { + 'wt-1': [{ ...structuredTab, agentSessionAgent: undefined }] } }) render() diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 922d16924f3f..8c33970b1610 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -25,6 +25,8 @@ export type NativeChatStructuredComposerTransport = { /** The `/` surface the running session reports. Absent keeps the curated * per-agent catalog, which is what an older host leaves the client with. */ sessionCommands?: readonly AgentSessionSlashCommand[] + /** False when the agent takes no image input, as its host registered it. */ + acceptsImages?: boolean /** The session's context usage; null until the journal can state it. */ contextUsage?: StructuredAgentContextUsage | null worktreeId?: string diff --git a/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts b/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts new file mode 100644 index 000000000000..be865276d2c9 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import { NO_AGENT_SESSION_CAPABILITIES } from '../../../../shared/agent-session-capabilities' +import type { Tab } from '../../../../shared/tab-types' +import { parseWorkspaceSession } from '../../../../shared/workspace-session-schema' + +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}), subscribe: () => () => {} } })) + +import { structuredAgentAcceptsImages } from '@/runtime/use-host-structured-agent' +import { isStructuredTab } from './structured-agent-session-tabs' +import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog' + +const chatTab = (agentSessionAgent: unknown) => ({ + id: 'agent-session:grok_1', + entityId: 'grok_1', + groupId: 'group-1', + worktreeId: 'wt', + contentType: 'agent-session', + agentSessionAgent, + label: 'Grok Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 +}) + +function reloadedTab(agentSessionAgent: unknown): Tab { + const parsed = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { wt: [chatTab(agentSessionAgent)] } + }) + if (!parsed.ok || !parsed.value.unifiedTabs?.wt[0]) { + throw new Error('session did not parse') + } + return parsed.value.unifiedTabs.wt[0] +} + +describe('a host-registered agent on the structured chat surface', () => { + it("renders a registered agent's chat tab, including after a reload", () => { + expect(isStructuredTab(reloadedTab('grok'))).toBe(true) + expect(isStructuredTab(reloadedTab('claude'))).toBe(true) + // A malformed id degrades to no agent, which mounts no chat pane. + expect(isStructuredTab(reloadedTab('not an agent!'))).toBe(false) + }) + + it("starts an unshipped agent's picker empty and fills it only from the session", () => { + expect(structuredAgentSessionSeedCatalog('grok').models).toEqual([]) + expect(structuredAgentSessionSeedCatalog('claude')).toBe(getAgentSessionOptionCatalog('claude')) + expect(structuredAgentSessionSeedCatalog('codex')).toBe(getAgentSessionOptionCatalog('codex')) + }) + + it("attaches images only when the host's record for the agent takes them", () => { + const record = (imagePrompts: boolean) => ({ + agent: 'grok', + capabilities: { ...NO_AGENT_SESSION_CAPABILITIES, imagePrompts } + }) + expect(structuredAgentAcceptsImages(record(false), 'grok')).toBe(false) + expect(structuredAgentAcceptsImages(record(true), 'grok')).toBe(true) + // Unlisted: an older host's built-ins keep images; any other agent claims none. + expect(structuredAgentAcceptsImages(undefined, 'claude')).toBe(true) + expect(structuredAgentAcceptsImages(undefined, 'grok')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-seed-catalog.ts b/src/renderer/src/components/native-chat/structured-agent-session-seed-catalog.ts new file mode 100644 index 000000000000..690a70a1388a --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-seed-catalog.ts @@ -0,0 +1,21 @@ +import { + getAgentSessionOptionCatalog, + type AgentSessionOptionCatalog +} from '../../../../shared/agent-session-option-catalog' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import type { AgentType } from '../../../../shared/agent-status-types' + +// Over `agentSession.*` only the live read and `setOption` apply a pick, so no launch apply is named. +const LIVE_ONLY_CATALOG: AgentSessionOptionCatalog = { models: [], modelApply: {} } + +/** + * What a structured chat's picker shows before the session reports its own options. Only the agents + * every build ships have a seed written against their structured option ids; another agent's + * terminal catalog names CLI flags and models its structured session never offered, so its picker + * starts empty and lists exactly what `agentSession.options` reports. + */ +export function structuredAgentSessionSeedCatalog(agent: AgentType): AgentSessionOptionCatalog { + return isAgentSessionHandleProvider(agent) + ? (getAgentSessionOptionCatalog(agent) ?? LIVE_ONLY_CATALOG) + : LIVE_ONLY_CATALOG +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts b/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts index 3c4819c6dc05..a3bbcd97830e 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts @@ -1,10 +1,11 @@ -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { isStructuredAgentId } from '../../../../shared/agent-session-provider-handle' import type { Tab } from '../../../../shared/tab-types' export type StructuredTab = Tab & { contentType: 'agent-session' } +/** A chat tab of any agent its host registered; the host publishes tabs of no other agent. */ export function isStructuredTab(tab: Tab): tab is StructuredTab { - return tab.contentType === 'agent-session' && isAgentSessionHandleProvider(tab.agentSessionAgent) + return tab.contentType === 'agent-session' && isStructuredAgentId(tab.agentSessionAgent) } const structuredTabsByUnifiedTabsSnapshot = new WeakMap< diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts index 697c380b8270..34d3dcb9ac3f 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts @@ -9,7 +9,6 @@ import { import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import type { NativeChatSessionOptionRecord } from '../../../../shared/native-chat-session-option-state' -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' /** * Upgrades the static seed with the host's stored catalog without waiting on @@ -48,7 +47,8 @@ export function useHostModelCatalogUpgrade(args: { worktree } = args useEffect(() => { - if (!enabled || !optionCatalog || !isAgentSessionHandleProvider(agent)) { + // Any agent the host registered: it answers `unknown` for one whose catalog it does not keep. + if (!enabled || !optionCatalog) { return } let stale = false diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index 341e242901fa..987d27e86680 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -12,6 +12,8 @@ import { useNativeChatResolvedPathAttachments } from './use-native-chat-resolved export type UseNativeChatComposerAttachmentsArgs = { attachmentScopeKey: string + /** False when the agent takes no image input; see `useNativeChatResolvedPathAttachments`. */ + acceptsImages?: boolean allowWithoutTarget?: boolean caret: number disabled: boolean @@ -25,6 +27,7 @@ export type UseNativeChatComposerAttachmentsArgs = { export function useNativeChatComposerAttachments({ attachmentScopeKey, + acceptsImages = true, allowWithoutTarget = false, caret, disabled, @@ -122,6 +125,7 @@ export function useNativeChatComposerAttachments({ const { attachResolvedPaths, disabledRef, flushPendingAttachments } = useNativeChatResolvedPathAttachments({ + acceptsImages, appendImageAttachments, attachmentTargetBlocked, caret, @@ -138,7 +142,8 @@ export function useNativeChatComposerAttachments({ // takes a beat to save (or upload over SSH) never reads as a dropped paste. const beginPendingImageAttachment = useCallback( (previewUrl?: string): string | null => { - if (disabledRef.current) { + // Without image input the saved paste is attached by path once it lands, so no image chip. + if (disabledRef.current || !acceptsImages) { return null } if (attachmentTargetBlocked()) { @@ -150,6 +155,7 @@ export function useNativeChatComposerAttachments({ return id }, [ + acceptsImages, attachmentTargetBlocked, disabledRef, nextAttachmentId, diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx new file mode 100644 index 000000000000..661669ab6775 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, createElement, useEffect, useRef, useState } from 'react' +import { createRoot } from 'react-dom/client' +import { + clearNativeChatAttachmentCacheForTests, + useNativeChatComposerAttachments +} from './use-native-chat-composer-attachments' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +type AttachmentApi = ReturnType + +function Probe(props: { + acceptsImages: boolean + onReady: (api: AttachmentApi) => void +}): React.JSX.Element { + const [caret, setCaret] = useState(0) + const [draft, setDraft] = useState('') + const textareaRef = useRef(null) + const api = useNativeChatComposerAttachments({ + attachmentScopeKey: 'structured-session', + allowWithoutTarget: true, + acceptsImages: props.acceptsImages, + caret, + disabled: false, + isComposing: () => false, + resolveTarget: () => null, + textareaRef, + setCaret, + setDraft: (updater) => setDraft((previous) => updater(previous)), + setNotice: () => {} + }) + const { onReady } = props + useEffect(() => { + onReady(api) + }, [api, onReady]) + return createElement('div', null, [ + createElement('textarea', { key: 'input', ref: textareaRef }), + createElement('output', { key: 'draft', 'data-draft': true }, draft) + ]) +} + +async function attach(acceptsImages: boolean, path: string) { + const container = document.createElement('div') + document.body.append(container) + let api: AttachmentApi | null = null + const root = createRoot(container) + await act(async () => { + root.render( + createElement(Probe, { + acceptsImages, + onReady: (next) => { + api = next + } + }) + ) + }) + const latest = (): AttachmentApi => { + if (!api) { + throw new Error('Probe did not render') + } + return api + } + const pendingChip = latest().beginPendingImageAttachment('blob:preview') + await act(async () => latest().attachResolvedPaths([path])) + const result = { + chips: latest().imageAttachments.map((attachment) => attachment.path), + pendingChip, + draft: container.querySelector('[data-draft]')?.textContent ?? '' + } + act(() => root.unmount()) + return result +} + +describe('structured composer image input', () => { + afterEach(() => { + clearNativeChatAttachmentCacheForTests() + document.body.replaceChildren() + }) + + it('holds an image as a chip when the agent takes images', async () => { + const result = await attach(true, '/tmp/shot.png') + expect(result.chips).toContain('/tmp/shot.png') + expect(result.pendingChip).not.toBeNull() + }) + + it('references an image by path, with no chip, when the agent takes none', async () => { + const result = await attach(false, '/tmp/shot.png') + expect(result.chips).toEqual([]) + expect(result.pendingChip).toBeNull() + expect(result.draft).toContain('/tmp/shot.png') + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts index 7bc6a042abcd..e33466a86ca1 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts @@ -16,6 +16,8 @@ type ResolvedAttachmentPath = { } type Args = { + /** False when the agent takes no image input: an image is then referenced by path like any file. */ + acceptsImages?: boolean appendImageAttachments: (paths: { path: string; connectionId?: string | null }[]) => void attachmentTargetBlocked: (targetOwned?: boolean) => boolean caret: number @@ -29,6 +31,7 @@ type Args = { } export function useNativeChatResolvedPathAttachments({ + acceptsImages = true, appendImageAttachments, attachmentTargetBlocked, caret, @@ -107,9 +110,11 @@ export function useNativeChatResolvedPathAttachments({ noteAttachmentTargetBlocked() return } - const imagePaths = attachable.filter(({ path }) => isNativeChatImageAttachmentPath(path)) + const attachesAsImage = (path: string): boolean => + acceptsImages && isNativeChatImageAttachmentPath(path) + const imagePaths = attachable.filter(({ path }) => attachesAsImage(path)) const filePaths = attachable - .filter(({ path }) => !isNativeChatImageAttachmentPath(path)) + .filter(({ path }) => !attachesAsImage(path)) .map(({ path }) => path) // Images ride along on submit so chips and the TUI input cannot diverge. appendImageAttachments(imagePaths.map(({ path, connectionId }) => ({ path, connectionId }))) @@ -124,6 +129,7 @@ export function useNativeChatResolvedPathAttachments({ } }, [ + acceptsImages, appendImageAttachments, attachmentTargetBlocked, insertFileReferences, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts index 3f54875e4054..8c1bc404508b 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts @@ -2,10 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { AgentSessionConversationCommand } from '../../../../shared/agent-session-conversation-command' import type { AgentSessionOptionsResult } from '../../../../shared/agent-session-wire' import type { AgentType } from '../../../../shared/agent-status-types' -import { - getAgentSessionOptionCatalog, - type AgentSessionOptionCatalog -} from '../../../../shared/agent-session-option-catalog' +import type { AgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog' import { applyStructuredAgentSessionOptions, createStructuredAgentSessionOptionState, @@ -76,7 +74,7 @@ export function useStructuredAgentSessionOptionState(args: { optionIdentityRef.current = identity const seeded = createStructuredAgentSessionOptionState( agent, - getAgentSessionOptionCatalog(agent) + structuredAgentSessionSeedCatalog(agent) ) // A host catalog is the account's, not the fence's: keep it rather than blank the default. const next = diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index 549d5ca3ff7f..73b45f7f2601 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -5,7 +5,7 @@ import type { AgentSessionOptionsResult } from '../../../../shared/agent-session-wire' import type { AgentType } from '../../../../shared/agent-status-types' -import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog' import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' import { applyStructuredAgentSessionOptions, @@ -63,7 +63,7 @@ export function useStructuredAgentSessionOptions(args: { const held = launch?.heldOptions ?? NO_HELD_OPTIONS // Published but not attached: the launch no longer holds picks and there is no fence to send one. const acceptsPicks = !transportEnabled || fence !== null - const optionCatalog = useMemo(() => getAgentSessionOptionCatalog(agent), [agent]) + const optionCatalog = useMemo(() => structuredAgentSessionSeedCatalog(agent), [agent]) const identity = `${agent}:${sessionId}` const { optionState, diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 026944575c83..0a3dbcd0382f 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -8,7 +8,6 @@ import { useAgentDetectionTargetForWorktree } from '@/hooks/useAgentDetectionTar import { useDetectedAgents } from '@/hooks/useDetectedAgents' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import type { TuiAgent } from '../../../../shared/tui-agent' import type { LaunchSource } from '../../../../shared/telemetry-events' import { @@ -16,7 +15,7 @@ import { filterEnabledTuiAgents } from '../../../../shared/tui-agent-selection' import { translate } from '@/i18n/i18n' -import { useStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch' +import { useStructuredAgentLaunchPendingAgents } from '@/lib/structured-agent-session-launch' export type QuickLaunchAgentMenuItemsProps = { worktreeId: string @@ -118,12 +117,7 @@ function QuickLaunchAgentMenuItemsInner({ const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent') - // One hook per structured provider: the launch registry is keyed by agent, and hooks cannot run - // inside the agent list's render loop. - const structuredLaunchStatusByAgent = { - claude: useStructuredAgentLaunchStatus(worktreeId, 'claude'), - codex: useStructuredAgentLaunchStatus(worktreeId, 'codex') - } + const pendingStructuredLaunchAgents = useStructuredAgentLaunchPendingAgents(worktreeId) const openAgentSettings = useCallback(() => { openSettingsTarget({ pane: 'agents', repoId: null }) @@ -203,8 +197,7 @@ function QuickLaunchAgentMenuItemsInner({ {agents.map((agent) => { const entry = getCatalogEntry(agent) const label = entry?.label ?? agent - const isStructuredLaunchPending = - isAgentSessionHandleProvider(agent) && structuredLaunchStatusByAgent[agent] === 'pending' + const isStructuredLaunchPending = pendingStructuredLaunchAgents.has(agent) const showsDefaultAgentShortcut = newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent return ( diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx index 8eab22f3c8cf..465dceecbb14 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx @@ -39,7 +39,8 @@ vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null })) vi.mock('@/lib/structured-agent-session-launch', () => ({ - useStructuredAgentLaunchStatus: () => structuredLaunchMock.status + useStructuredAgentLaunchPendingAgents: () => + new Set(structuredLaunchMock.status === 'pending' ? ['codex'] : []) })) import TabBarCreateEntry from './TabBarCreateEntry' diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx index d0766a7b1cfc..57075a2d7657 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx @@ -33,8 +33,7 @@ import { getTabEntryOmniboxPlaceholder } from './tab-create-entry-copy' import { EMPTY_AGENT_OPTIONS, EMPTY_MENU_OPTIONS } from './tab-create-entry-empty-options' -import { useStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch' -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { useStructuredAgentLaunchPendingAgents } from '@/lib/structured-agent-session-launch' import type { TuiAgent } from '../../../../shared/tui-agent' import type { TabEntryActionClassification } from './tab-create-entry-classifier' import type { TabBarCreateEntryProps } from './tab-create-entry-props' @@ -62,14 +61,9 @@ function TabBarCreateEntrySession({ const [error, setError] = useState(null) const [switchError, setSwitchError] = useState(null) const [selectionGuidance, setSelectionGuidance] = useState(null) - // One hook per structured provider: the launch registry is keyed by agent, and hooks cannot run - // inside the option render loop. - const structuredLaunchStatusByAgent = { - claude: useStructuredAgentLaunchStatus(worktreeId, 'claude'), - codex: useStructuredAgentLaunchStatus(worktreeId, 'codex') - } + const pendingStructuredLaunchAgents = useStructuredAgentLaunchPendingAgents(worktreeId) const isStructuredLaunchPending = (agent: TuiAgent): boolean => - isAgentSessionHandleProvider(agent) && structuredLaunchStatusByAgent[agent] === 'pending' + pendingStructuredLaunchAgents.has(agent) // null = follow ranking (deferred tabs can prepend); set on arrow keys only. const [pinnedOptionId, setPinnedOptionId] = useState(null) const inputRef = useRef(null) diff --git a/src/renderer/src/components/tab-bar/TabBarItemRow.tsx b/src/renderer/src/components/tab-bar/TabBarItemRow.tsx index ead0645c7a59..6e966d03b167 100644 --- a/src/renderer/src/components/tab-bar/TabBarItemRow.tsx +++ b/src/renderer/src/components/tab-bar/TabBarItemRow.tsx @@ -3,8 +3,7 @@ import { useTranslation } from 'react-i18next' import { shallow } from 'zustand/shallow' import type { GitFileStatus } from '../../../../shared/git-status-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import type { TuiAgent } from '../../../../shared/tui-agent' -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { OpenFile } from '../../store/slices/editor' import SortableTab from './SortableTab' import EditorFileTab from './EditorFileTab' @@ -115,8 +114,8 @@ function TabBarItemRow({ color: item.data.color, sortOrder: item.data.sortOrder, createdAt: item.data.createdAt, - ...(isAgentSessionHandleProvider(item.data.agentSessionAgent) - ? { launchAgent: item.data.agentSessionAgent as TuiAgent } + ...(isTuiAgent(item.data.agentSessionAgent) + ? { launchAgent: item.data.agentSessionAgent } : {}) } return ( diff --git a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx index ef45ae6cb3ec..db2977de3c5e 100644 --- a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx +++ b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx @@ -34,7 +34,8 @@ vi.mock('@/lib/worktree-agent-activation-gate', () => ({ gateWorktreeAgentActivation: mocks.gate })) vi.mock('@/lib/structured-agent-session-launch', () => ({ - getStructuredAgentLaunchStatus: mocks.launchStatus + hasStructuredAgentLaunchInWorktree: (worktreeId: string) => + ['claude', 'codex', 'grok'].some((agent) => mocks.launchStatus(worktreeId, agent) !== 'idle') })) vi.mock('@/lib/resume-sleeping-agent-session', () => ({ resumeSleepingAgentSessionsForWorktree: mocks.resume @@ -105,6 +106,7 @@ describe('passive terminal seeding during native chat creation', () => { ['codex', 'pending', 0], ['claude', 'unknown', 0], ['codex', 'unknown', 0], + ['grok', 'pending', 0], ['claude', 'idle', 1] ] as const)('handles %s launch status %s', async (agent, status, expectedTabs) => { let finishGate!: (outcome: 'empty') => void diff --git a/src/renderer/src/components/use-terminal-watcher-effects.ts b/src/renderer/src/components/use-terminal-watcher-effects.ts index 39764c873457..bef809166b2d 100644 --- a/src/renderer/src/components/use-terminal-watcher-effects.ts +++ b/src/renderer/src/components/use-terminal-watcher-effects.ts @@ -13,9 +13,8 @@ import { useAppStore } from '@/store' import { isTerminalWorkspaceEmptiedOnPurpose } from '../../../shared/closed-terminal-tab-tombstones' import { gateWorktreeAgentActivation } from '@/lib/worktree-agent-activation-gate' import { createWorkspaceTerminalHostAuthoritySelector } from '@/lib/workspace-terminal-host-authority' -import { getStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch' +import { hasStructuredAgentLaunchInWorktree } from '@/lib/structured-agent-session-launch' import { isEmptyWorkspaceDefaultSurfacePending } from '@/lib/empty-workspace-default-surface-claims' -import { AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS } from '../../../shared/agent-session-provider-handle' import type { TerminalColdActivationController } from './terminal-cold-activation' import { selectParkedEquivalentMountTabIds } from './terminal/startup-terminal-tab-hold' @@ -236,11 +235,7 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController) return } // A pending or unanswered chat create owns the surface even before its tab is published. - if ( - AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS.some( - (agent) => getStructuredAgentLaunchStatus(activeWorktreeId, agent) !== 'idle' - ) - ) { + if (hasStructuredAgentLaunchInWorktree(activeWorktreeId)) { return } // Why: the activation gate reconciles durable/live agent state first; only an actually empty, never-visited workspace receives a default shell. diff --git a/src/renderer/src/lib/agent-launch-route-input.ts b/src/renderer/src/lib/agent-launch-route-input.ts index 1d23ef6c2130..416b873647df 100644 --- a/src/renderer/src/lib/agent-launch-route-input.ts +++ b/src/renderer/src/lib/agent-launch-route-input.ts @@ -28,6 +28,7 @@ import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { readLocalRuntimeCapabilitiesOrUnknown } from '@/runtime/local-runtime-capabilities' import { resolveStructuredAgentSessionOwner } from '@/runtime/structured-agent-session-owner' import { pairedHostClientCapabilities } from '@/runtime/paired-host-client-capabilities' +import { readHostStructuredAgents } from '@/runtime/host-structured-agents' import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' export type ProspectiveWorkspaceKind = NonNullable @@ -126,6 +127,14 @@ function resolveTranscriptIsLocalReadable( return host?.kind === 'ssh' ? isNativeChatTranscriptLocalReadable(host.targetId) : true } +function hostStructuredAgentsInput( + store: AgentLaunchRouteStore, + executionHostId: string +): Pick { + const agents = readHostStructuredAgents(executionHostId, store.runtimeStatusByEnvironmentId) + return agents ? { hostStructuredAgents: agents.map((row) => row.agent) } : {} +} + /** The one place that gathers what a launch route decision needs; only the planner resolves on it. */ export function buildAgentLaunchRouteInput( store: AgentLaunchRouteStore, @@ -170,6 +179,7 @@ export function buildAgentLaunchRouteInput( // A launch command override is this machine's; a paired host's createSupport reads its own. (executionHostId === LOCAL_EXECUTION_HOST_ID && hasExplicitTuiLaunchCommand(store.settings, agent)), - initialSessionOptions: args.initialSessionOptions + initialSessionOptions: args.initialSessionOptions, + ...hostStructuredAgentsInput(store, executionHostId) } } diff --git a/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts b/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts new file mode 100644 index 000000000000..d9b832c87cb8 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../../shared/protocol-version' +import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' +import type { TuiAgent } from '../../../shared/tui-agent' + +const mocks = vi.hoisted(() => ({ + readLocalRuntimeCapabilitiesOrUnknown: vi.fn(), + callRuntimeRpc: vi.fn() +})) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ getExecutionHostIdForWorktree: vi.fn() })) +vi.mock('@/lib/local-preflight-context', () => ({ + getLocalProjectExecutionRuntimeContext: vi.fn(), + getLocalRepoProjectExecutionRuntimeContext: vi.fn() +})) +vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => false })) +vi.mock('@/runtime/local-runtime-capabilities', () => ({ + readLocalRuntimeCapabilitiesOrUnknown: mocks.readLocalRuntimeCapabilitiesOrUnknown +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) +vi.mock('@/lib/structured-agent-launch-settlement', () => ({})) + +import { + loadHostStructuredAgents, + resetHostStructuredAgentsForTests +} from '@/runtime/host-structured-agents' +import type { AgentLaunchRouteStore, ProspectiveWorkspace } from './agent-launch-route-input' +import { planAgentSessionLaunch } from './agent-session-launch-plan' + +const STRUCTURED: RuntimeCapability[] = [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +const REGISTERED: RuntimeCapability[] = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY +] +const LISTS_GROK = { + agents: [ + { agent: 'claude', capabilities: {} }, + { agent: 'codex', capabilities: {} }, + { agent: 'grok', capabilities: {} } + ] +} +const SETTINGS = { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + experimentalStructuredNativeChat: true +} +const LOCAL: ProspectiveWorkspace = { kind: 'git-worktree', repoId: 'repo-1' } +const PAIRED: ProspectiveWorkspace = { kind: 'git-worktree', runtimeEnvironmentId: 'env-1' } + +function pairedStatus(runtimeId: string): RuntimeEnvironmentStatus { + return { + status: { + runtimeId, + capabilities: REGISTERED, + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0 + }, + checkedAt: 1 + } +} + +function store( + overrides: { settings?: Partial; runtimeId?: string } = {} +): AgentLaunchRouteStore { + return { + settings: { ...SETTINGS, ...overrides.settings }, + runtimeStatusByEnvironmentId: new Map([['env-1', pairedStatus(overrides.runtimeId ?? 'rt-1')]]), + worktreesByRepo: {}, + folderWorkspaces: [] + } +} + +const routeFor = (appStore: AgentLaunchRouteStore, agent: TuiAgent, workspace = LOCAL) => + planAgentSessionLaunch(appStore, { agent, workspace }).route + +beforeEach(() => { + resetHostStructuredAgentsForTests() + mocks.callRuntimeRpc.mockReset().mockResolvedValue(LISTS_GROK) + mocks.readLocalRuntimeCapabilitiesOrUnknown.mockReset().mockReturnValue(REGISTERED) +}) + +describe('structured launch of a host-registered agent', () => { + it('opens a chat for an agent the local host listed', async () => { + await loadHostStructuredAgents('local', REGISTERED, null) + + expect(routeFor(store(), 'grok')).toBe('structured-native-chat') + }) + + it('never offers it from a host without the registered-agents capability', async () => { + await loadHostStructuredAgents('local', REGISTERED, null) + mocks.readLocalRuntimeCapabilitiesOrUnknown.mockReturnValue(STRUCTURED) + + expect(routeFor(store(), 'grok')).not.toBe('structured-native-chat') + // The agents every build ships stay offered there. + expect(routeFor(store(), 'claude')).toBe('structured-native-chat') + }) + + it('does not offer it before the host has listed it', () => { + expect(routeFor(store(), 'grok')).not.toBe('structured-native-chat') + expect(routeFor(store(), 'codex')).toBe('structured-native-chat') + }) + + it('does not offer an agent the host did not list', async () => { + await loadHostStructuredAgents('local', REGISTERED, null) + + expect(routeFor(store(), 'gemini')).not.toBe('structured-native-chat') + }) + + it('keeps the experimental chat setting authoritative', async () => { + await loadHostStructuredAgents('local', REGISTERED, null) + + expect( + routeFor(store({ settings: { experimentalStructuredNativeChat: false } }), 'grok') + ).not.toBe('structured-native-chat') + }) + + it("reads a paired host's list only from the runtime that gave it", async () => { + await loadHostStructuredAgents('runtime:env-1', REGISTERED, 'rt-1') + + expect(routeFor(store(), 'grok', PAIRED)).toBe('structured-native-chat') + expect(routeFor(store({ runtimeId: 'rt-2' }), 'grok', PAIRED)).not.toBe( + 'structured-native-chat' + ) + }) +}) diff --git a/src/renderer/src/lib/agent-launch-routing.ts b/src/renderer/src/lib/agent-launch-routing.ts index 04850cdf91db..f0105d07e7dc 100644 --- a/src/renderer/src/lib/agent-launch-routing.ts +++ b/src/renderer/src/lib/agent-launch-routing.ts @@ -38,13 +38,15 @@ export type AgentLaunchRoutingInput = { nativeChatTranscriptIsLocalReadable?: boolean requiresTuiLaunchCommand?: boolean initialSessionOptions?: Readonly> + /** The agents the target host listed as structured; absent until it has. */ + hostStructuredAgents?: readonly string[] } export function resolveAgentLaunchRoute(input: AgentLaunchRoutingInput): AgentLaunchRoute { // Why: structured eligibility is decided before the view-mode decider. That decider applies the // terminal mirror gate (a TUI cannot clear more than forty lines of prefilled draft), which has // no meaning for a session that seeds the composer store directly. Its other gates are already - // implied here: the structured resolver admits only claude/codex, both native-chat agents, and + // implied here: the structured resolver admits only agents the host runs as chats, and // only hosts with an Orca runtime, and a structured session reads its journal over RPC rather // than the transcript file, so local transcript readability does not apply either. if ( @@ -77,7 +79,8 @@ export function structuredAgentLaunchSupported( ...(input.clientCapabilities ? { clientCapabilities: input.clientCapabilities } : {}), workspaceKind: input.workspaceKind, projectRuntime: input.projectRuntime, - requiresTuiLaunchCommand: input.requiresTuiLaunchCommand + requiresTuiLaunchCommand: input.requiresTuiLaunchCommand, + ...(input.hostStructuredAgents ? { hostStructuredAgents: input.hostStructuredAgents } : {}) }).supported ) } diff --git a/src/renderer/src/lib/agent-session-launch-plan.test.ts b/src/renderer/src/lib/agent-session-launch-plan.test.ts index d8a634dc5788..c623fc79fda7 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.test.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.test.ts @@ -107,9 +107,26 @@ describe('planAgentSessionLaunch', () => { ) }) - it('returns null for an agent that cannot hold a structured session even on the structured route', async () => { + // The route is the one gate: it admits only an agent the host registered as structured. + it('launches any agent the structured route admitted', async () => { const plan = planAgentSessionLaunch(store, { - agent: 'gemini', + agent: 'grok', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' } + }) + + await expect(plan.launch(hooks)).resolves.toEqual(STRUCTURED) + expect(mocks.beginStructuredAgentLaunchSettlement).toHaveBeenCalledWith( + 'wt-1', + 'grok', + { executionHostId: 'local' }, + hooks + ) + }) + + it('opens nothing structured on a route that is not', async () => { + mocks.resolveAgentLaunchRoute.mockReturnValue('legacy-native-chat') + const plan = planAgentSessionLaunch(store, { + agent: 'grok', workspace: { kind: 'git-worktree', worktreeId: 'wt-1' } }) diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts index d4fa18551d45..088a7bbd4417 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -1,4 +1,3 @@ -import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' import type { TuiAgent } from '../../../shared/tui-agent' import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' @@ -90,7 +89,8 @@ function beginStructuredPlanLaunch( hooks: StructuredAgentLaunchHooks, target?: AgentSessionLaunchTarget ): StructuredAgentLaunchHandle | null { - if (verdict.route !== 'structured-native-chat' || !isAgentSessionHandleProvider(verdict.agent)) { + // The route already admitted the agent: its host registered it as structured. + if (verdict.route !== 'structured-native-chat') { return null } const worktreeId = target?.worktreeId ?? verdict.worktreeId diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index 0f807928d3d7..5a84fafd92c0 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import type { AgentSessionAttachResult, AgentSessionMutationResult @@ -52,7 +52,7 @@ export type StructuredAgentSessionLaunchIntent = { executionHostId: ExecutionHostId /** The runtime serving `executionHostId`. */ target: RuntimeClientTarget - agent: AgentSessionHandleProvider + agent: TuiAgent params: StructuredAgentSessionCreateParams /** The saved selection create seeds, read when the intent is built. */ seedOptions?: Readonly> @@ -65,7 +65,7 @@ type LaunchSeed = Readonly> | undefined function launchSeedOptions( state: ReturnType, owner: Pick, - agent: AgentSessionHandleProvider, + agent: TuiAgent, hostSeedOptions: LaunchSeed ): { seedOptions?: Readonly> } { const seedOptions = @@ -111,7 +111,7 @@ function definitiveStructuredAgentSessionCreateErrorCode(error: unknown): string * one, or the launch is refused rather than sent to whichever host a fallback picks. */ export function createStructuredAgentSessionLaunchIntent( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, executionHostId?: ExecutionHostId, resumeFrom?: StructuredAgentSessionResumeSource, hostSeedOptions?: LaunchSeed @@ -134,7 +134,7 @@ export function createStructuredAgentSessionLaunchIntent( function buildStructuredAgentSessionLaunchIntent( worktreeId: string, owner: Pick, - agent: AgentSessionHandleProvider, + agent: TuiAgent, sessionId: string, resumeFrom: StructuredAgentSessionResumeSource | undefined, hostSeedOptions: LaunchSeed @@ -183,7 +183,7 @@ export function restoreStructuredAgentSessionLaunchIntent(args: { worktreeId: string executionHostId: ExecutionHostId sessionId: string - agent: AgentSessionHandleProvider + agent: TuiAgent clientOperationId: string payloadFingerprint: string expectedRuntimeFence: number | null diff --git a/src/renderer/src/lib/structured-agent-launch-settlement.ts b/src/renderer/src/lib/structured-agent-launch-settlement.ts index 9815f71b48aa..044f61b32130 100644 --- a/src/renderer/src/lib/structured-agent-launch-settlement.ts +++ b/src/renderer/src/lib/structured-agent-launch-settlement.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import type { ExecutionHostId } from '../../../shared/execution-host' import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' import { @@ -97,7 +97,7 @@ async function settleStartedStructuredAgentLaunch( /** Exposes the durable identity before host acquisition so its chat can render immediately. */ export function beginStructuredAgentLaunchSettlement( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, options: StructuredAgentLaunchOptions, hooks: StructuredAgentLaunchHooks ): StructuredAgentLaunchHandle { @@ -114,7 +114,7 @@ export function beginStructuredAgentLaunchSettlement( /** Compatibility wrapper for callers that do not need the provisional identity. */ export function settleStructuredAgentLaunch( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, options: StructuredAgentLaunchOptions, hooks: StructuredAgentLaunchHooks ): Promise { diff --git a/src/renderer/src/lib/structured-agent-session-host-admission.ts b/src/renderer/src/lib/structured-agent-session-host-admission.ts index 3474ce1c45dd..97b1467888db 100644 --- a/src/renderer/src/lib/structured-agent-session-host-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-host-admission.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import { isDefinitiveAgentSessionCreateRefusal } from '../../../shared/agent-session-definitive-refusal' import { parseStructuredLaunchSeedOptions } from '../../../shared/native-chat-session-option-defaults' import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' @@ -46,7 +46,7 @@ export type HostCreateSupport = export async function askHostCreateSupport( target: RuntimeClientTarget, worktree: string, - agent: AgentSessionHandleProvider + agent: TuiAgent ): Promise { for (let attempt = 0; ; attempt += 1) { try { @@ -84,7 +84,7 @@ export async function askHostCreateSupport( export async function admitStructuredLaunchOnHost( target: RuntimeClientTarget, worktree: string, - agent: AgentSessionHandleProvider + agent: TuiAgent ): Promise { const support = await askHostCreateSupport(target, worktree, agent) return support.kind === 'unreachable' ? { kind: 'unreachable' } : support diff --git a/src/renderer/src/lib/structured-agent-session-launch-draft.ts b/src/renderer/src/lib/structured-agent-session-launch-draft.ts index 2e1447a17787..40fd721440b1 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-draft.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-draft.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' import { useAppStore } from '@/store' import type { StructuredAgentLaunchOptions } from './structured-agent-session-launch-callers' @@ -7,7 +7,7 @@ import type { StructuredAgentLaunchOptions } from './structured-agent-session-la * and a structured session has no TUI copy, so a gated seed here would be a lost prompt. */ export function seedStructuredAgentLaunchDraft( sessionId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, options: StructuredAgentLaunchOptions ): void { if (options.promptDelivery !== 'draft' || !options.prompt) { diff --git a/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts b/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts index bd41ed9733c2..e9fbc0180302 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts @@ -50,6 +50,28 @@ describe('structured agent launch persistence', () => { }) }) + it("keeps a registered agent's launch across a reload and drops a record naming no agent", () => { + localStorage.setItem( + 'orca:structuredAgentLaunches:v1', + JSON.stringify( + [ + ['grok_session', 'grok'], + ['bogus_session', 'not-an-agent'] + ].map(([sessionId, agent]) => ({ + sessionId, + agent, + lifecycle: 'pending', + clientOperationId: `${sessionId}-op`, + payloadFingerprint: 'fingerprint', + expectedRuntimeFence: null + })) + ) + ) + + expect(readStructuredAgentLaunchRecord('grok_session')?.agent).toBe('grok') + expect(readStructuredAgentLaunchRecord('bogus_session')).toBeUndefined() + }) + it('keeps the host a launch was created on across a reload', () => { writeStructuredAgentLaunchRecord({ sessionId: 'claude_remote', diff --git a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts index 6e7bc32fe6c2..fda34d0da63a 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts @@ -1,7 +1,5 @@ -import { - isAgentSessionHandleProvider, - type AgentSessionHandleProvider -} from '../../../shared/agent-session-provider-handle' +import { isTuiAgent } from '../../../shared/tui-agent-config' +import type { TuiAgent } from '../../../shared/tui-agent' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' import { parseStructuredLaunchSeedOptions } from '../../../shared/native-chat-session-option-defaults' import type { StructuredAgentSessionLaunchIntent } from './launch-structured-agent-session' @@ -18,7 +16,7 @@ export type StructuredAgentLaunchPersistedRecord = { /** The host the chat was created on. Records written before paired hosts could hold a chat lack * it and load as local, the only host a chat could then be launched on. */ executionHostId: ExecutionHostId - agent: AgentSessionHandleProvider + agent: TuiAgent lifecycle: StructuredAgentLaunchPersistedLifecycle clientOperationId: string payloadFingerprint: string @@ -93,7 +91,7 @@ function validRecord(value: unknown): value is Omit< (typeof executionHostId === 'string' && parseExecutionHostId(executionHostId) !== null)) && typeof sessionId === 'string' && sessionId.length > 0 && - isAgentSessionHandleProvider(agent) && + isTuiAgent(agent) && (lifecycle === 'pending' || lifecycle === 'visibility-unknown' || lifecycle === 'failed') && typeof clientOperationId === 'string' && typeof payloadFingerprint === 'string' && diff --git a/src/renderer/src/lib/structured-agent-session-launch-registered-agent.test.ts b/src/renderer/src/lib/structured-agent-session-launch-registered-agent.test.ts new file mode 100644 index 000000000000..799ce2009010 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-registered-agent.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' + +const mocks = vi.hoisted(() => ({ call: vi.fn() })) + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } })) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/lib/agent-catalog', () => ({ getAgentCatalog: () => [{ id: 'grok', label: 'Grok' }] })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) +vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ + LOCAL_STRUCTURED_SESSION_OWNER: 'local', + refreshLocalStructuredSessionTabs: vi.fn(async () => []) +})) +vi.mock('@/store', () => ({ + useAppStore: { getState: () => ({ unifiedTabsByWorktree: {} }), subscribe: () => () => {} } +})) + +import { + hasStructuredAgentLaunchInWorktree, + startStructuredAgentLaunch, + useStructuredAgentLaunchPendingAgents +} from './structured-agent-session-launch' + +async function pendingAgents(worktreeId: string): Promise { + let seen: string[] = [] + function Probe(): null { + seen = [...useStructuredAgentLaunchPendingAgents(worktreeId)] + return null + } + const root = createRoot(document.createElement('div')) + await act(async () => root.render(createElement(Probe))) + act(() => root.unmount()) + return seen +} + +describe('a launch of a host-registered agent', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mocks.call.mockImplementation(async (_target: unknown, method: string) => + method === 'agentSession.createSupport' ? { supported: true } : new Promise(() => {}) + ) + }) + + it('creates the chat for that agent and counts as a chat starting in the worktree', async () => { + const worktreeId = 'wt-grok' + expect(hasStructuredAgentLaunchInWorktree(worktreeId)).toBe(false) + + const launch = startStructuredAgentLaunch(worktreeId, 'grok') + await vi.waitFor(() => + expect(mocks.call).toHaveBeenCalledWith( + expect.anything(), + 'agentSession.create', + expect.objectContaining({ agent: 'grok', worktree: `id:${worktreeId}` }) + ) + ) + + expect(launch.sessionId.startsWith('grok_')).toBe(true) + expect(hasStructuredAgentLaunchInWorktree(worktreeId)).toBe(true) + expect(hasStructuredAgentLaunchInWorktree('wt-other')).toBe(false) + expect(await pendingAgents(worktreeId)).toEqual(['grok']) + expect(await pendingAgents('wt-other')).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts index 622fc58b3796..23d27fa52d9e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts @@ -1,5 +1,5 @@ import { useSyncExternalStore } from 'react' -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import type { AgentSessionWriteRefusal } from '../../../shared/agent-session-write-failure' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' import type { ExecutionHostId } from '../../../shared/execution-host' @@ -72,7 +72,7 @@ export function subscribeStructuredAgentLaunchStatus(listener: () => void): () = // Why keyed by conversation: a resume must not coalesce onto an unrelated blank launch. export function structuredLaunchIdentity( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, resumeFrom?: StructuredAgentSessionResumeSource ): string { return resumeFrom @@ -317,7 +317,7 @@ export function retireAbsentStructuredAgentSessionLaunchCancellationTombstones( export function getStructuredAgentLaunchStatus( worktreeId: string, - agent: AgentSessionHandleProvider + agent: TuiAgent ): StructuredAgentLaunchStatus { // Any launch for this pair, including adopted conversations, means a chat is starting here. const states = [ diff --git a/src/renderer/src/lib/structured-agent-session-launch-status.ts b/src/renderer/src/lib/structured-agent-session-launch-status.ts index 6ae37ff6553e..e01213cb3bff 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-status.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-status.ts @@ -1,17 +1,41 @@ -import { useSyncExternalStore } from 'react' -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import { useMemo, useSyncExternalStore } from 'react' +import type { TuiAgent } from '../../../shared/tui-agent' import { getStructuredAgentLaunchStatus, + structuredLaunchStates, subscribeStructuredAgentLaunchStatus } from './structured-agent-session-launch-registry' -export function useStructuredAgentLaunchStatus( - worktreeId: string, - agent: AgentSessionHandleProvider -): ReturnType { - return useSyncExternalStore( +/** One string per set, so the external-store snapshot stays stable between launches. */ +function pendingStructuredAgentLaunchesKey(worktreeId: string): string { + const agents = new Set() + for (const state of structuredLaunchStates()) { + if (state.intent.worktreeId === worktreeId) { + agents.add(state.intent.agent) + } + } + return [...agents] + .filter((agent) => getStructuredAgentLaunchStatus(worktreeId, agent) === 'pending') + .sort() + .join('\n') +} + +/** The agents whose chat is still starting in this worktree, whichever agents its host runs. */ +export function useStructuredAgentLaunchPendingAgents(worktreeId: string): ReadonlySet { + const key = useSyncExternalStore( subscribeStructuredAgentLaunchStatus, - () => getStructuredAgentLaunchStatus(worktreeId, agent), - () => 'idle' + () => pendingStructuredAgentLaunchesKey(worktreeId), + () => '' ) + return useMemo(() => new Set(key ? key.split('\n') : []), [key]) +} + +/** Whether any agent's chat create in this worktree is still unsettled: pending or unanswered. */ +export function hasStructuredAgentLaunchInWorktree(worktreeId: string): boolean { + for (const state of structuredLaunchStates()) { + if (state.intent.worktreeId === worktreeId) { + return true + } + } + return false } diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 5dd33c729ec2..751f220a8f3b 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import type { ExecutionHostId } from '../../../shared/execution-host' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { @@ -56,7 +56,10 @@ export { type StructuredAgentLaunchStatus, type StructuredAgentSessionLaunchLifecycle } from './structured-agent-session-launch-registry' -export { useStructuredAgentLaunchStatus } from './structured-agent-session-launch-status' +export { + hasStructuredAgentLaunchInWorktree, + useStructuredAgentLaunchPendingAgents +} from './structured-agent-session-launch-status' export { useStructuredAgentSessionLaunchSelection } from './structured-agent-session-launch-options' type StructuredLaunchStateResult = { @@ -151,7 +154,7 @@ function restartStructuredLaunchState(state: StructuredLaunchState): void { function structuredAgentLaunchState( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, options: StructuredAgentLaunchOptions ): StructuredLaunchStateResult { const identity = structuredLaunchIdentity(worktreeId, agent, options.resumeFrom) @@ -249,7 +252,7 @@ export function cancelStructuredAgentLaunch(worktreeId: string, sessionId: strin export function startStructuredAgentLaunch( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, options: StructuredAgentLaunchOptions = {} ): StructuredAgentLaunchResult { const { state, caller } = structuredAgentLaunchState(worktreeId, agent, options) diff --git a/src/renderer/src/lib/structured-agent-session-paired-admission.ts b/src/renderer/src/lib/structured-agent-session-paired-admission.ts index fe14eae025cb..7b977425671c 100644 --- a/src/renderer/src/lib/structured-agent-session-paired-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-paired-admission.ts @@ -1,5 +1,5 @@ import { toast } from 'sonner' -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import type { ExecutionHostId } from '../../../shared/execution-host' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' @@ -45,7 +45,7 @@ type AdmittedLaunch = { const DELIVERED: StructuredPromptDeliveryResult = { delivered: true, failureNotified: false } const NOT_DELIVERED: StructuredPromptDeliveryResult = { delivered: false, failureNotified: false } -function notifyHostDeclined(agent: AgentSessionHandleProvider): void { +function notifyHostDeclined(agent: TuiAgent): void { const agentLabel = structuredAgentLabel(agent) toast.info( translate( @@ -63,10 +63,7 @@ function notifyHostDeclined(agent: AgentSessionHandleProvider): void { ) } -function notifyHostUnreachable( - agent: AgentSessionHandleProvider, - executionHostId: ExecutionHostId -): void { +function notifyHostUnreachable(agent: TuiAgent, executionHostId: ExecutionHostId): void { const hostLabel = selectExecutionHostDisplayLabel(useAppStore.getState(), executionHostId) toast.error( translate( @@ -128,7 +125,7 @@ export async function openDeclinedStructuredLaunchTerminal(args: { * equivalent, fails); unreachable opens nothing and says so. There is nothing to undo either way. */ export function beginPairedStructuredLaunch(args: { - plan: AgentSessionLaunchPlan & { agent: AgentSessionHandleProvider } + plan: AgentSessionLaunchPlan & { agent: TuiAgent } hooks: StructuredAgentLaunchHooks worktreeId: string executionHostId: ExecutionHostId diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts index e0821dc47abe..83d21f2c6e71 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -11,10 +11,7 @@ import type { } from '@/lib/structured-agent-launch-settlement' import { useAppStore } from '@/store' import type { ExecutionHostId } from '../../../shared/execution-host' -import { - isAgentSessionHandleProvider, - type AgentSessionHandleProvider -} from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import { beginPairedStructuredLaunch, openDeclinedStructuredLaunchTerminal, @@ -40,7 +37,7 @@ export function openStructuredAgentSessionProvisionalTab(args: { /** The host the chat is created on; every later operation on the tab reads it. */ executionHostId: ExecutionHostId sessionId: string - agent: AgentSessionHandleProvider + agent: TuiAgent targetGroupId?: string activate?: boolean }): Tab { @@ -114,19 +111,15 @@ export function beginStructuredAgentSessionProvisionalLaunch( args: ProvisionalLaunchArgs ): StructuredAgentSessionProvisionalLaunch | null { const worktreeId = args.target?.worktreeId ?? args.plan.worktreeId - const agent = args.plan.agent - const paired = - worktreeId && isAgentSessionHandleProvider(agent) - ? structuredLaunchPairedOwner(args.plan, worktreeId, args.target) - : null - if (!paired || !worktreeId || !isAgentSessionHandleProvider(agent)) { + const paired = worktreeId ? structuredLaunchPairedOwner(args.plan, worktreeId, args.target) : null + if (!paired || !worktreeId) { return beginLocalProvisionalLaunch(args) } if (args.beforeOpen?.() === false) { return null } return beginPairedStructuredLaunch({ - plan: { ...args.plan, agent }, + plan: args.plan, hooks: args.hooks, worktreeId, executionHostId: paired.executionHostId, @@ -160,8 +153,8 @@ function beginLocalProvisionalLaunch(args: ProvisionalLaunchArgs): LocalProvisio return null } const worktreeId = args.target?.worktreeId ?? args.plan.worktreeId - if (!worktreeId || !isAgentSessionHandleProvider(args.plan.agent)) { - throw new Error('A provisional structured launch needs its workspace and provider.') + if (!worktreeId) { + throw new Error('A provisional structured launch needs its workspace.') } try { if (args.beforeOpen?.(handle.sessionId) === false) { diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts index 4a707a43a143..2c722b40b028 100644 --- a/src/renderer/src/lib/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts @@ -12,7 +12,6 @@ import { formatWorkspaceCreateError, getWorkspaceCreateErrorToastMessage } from '@/lib/workspace-create-error-format' -import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import type { CreateWorktreeResult } from '../../../shared/worktree/create-types' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { createBrowserUuid } from '@/lib/browser-uuid' @@ -274,11 +273,7 @@ export async function executeWorktreeCreation( let structuredLaunchAccepted = structuredLaunch const { agentLaunchRoute } = preparedRequest - const structuredAgent = preparedRequest.agent - if ( - agentLaunchRoute === 'structured-native-chat' && - isAgentSessionHandleProvider(structuredAgent) - ) { + if (agentLaunchRoute === 'structured-native-chat' && preparedRequest.agent) { let structuredSession: WorktreeCreationStructuredSessionResult | null = null try { structuredSession = await launchStructuredWorktreeSession({ diff --git a/src/renderer/src/lib/worktree-creation-structured-session.ts b/src/renderer/src/lib/worktree-creation-structured-session.ts index 6149a1554411..cd6df7bf4aa2 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.ts @@ -1,6 +1,5 @@ import { useAppStore } from '@/store' import { activateAndRevealWorktree, type ActivateAndRevealResult } from '@/lib/worktree-activation' -import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' import type { AgentLaunchRoute } from '@/lib/agent-launch-routing' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' @@ -49,7 +48,8 @@ export async function launchStructuredWorktreeSession( let { activation, primaryTabId } = args const settled = { accepted: true, cancelled: false } const { agent } = args.request - if (!isAgentSessionHandleProvider(agent)) { + // The composer's route already asked the host whether it runs this agent as a chat. + if (!agent || args.agentLaunchRoute !== 'structured-native-chat') { return { ...settled, activation, primaryTabId } } const isCancelled = (): boolean => diff --git a/src/renderer/src/runtime/host-structured-agents-sync.ts b/src/renderer/src/runtime/host-structured-agents-sync.ts new file mode 100644 index 000000000000..a4e9a1161ca2 --- /dev/null +++ b/src/renderer/src/runtime/host-structured-agents-sync.ts @@ -0,0 +1,33 @@ +import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { loadHostStructuredAgents, retainHostStructuredAgents } from './host-structured-agents' +import { ensureLocalRuntimeCapabilities } from './local-runtime-capabilities' + +function syncPairedHosts(statuses: AppState['runtimeStatusByEnvironmentId']): void { + const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) + for (const [environmentId, entry] of statuses) { + const executionHostId = toRuntimeExecutionHostId(environmentId) + hostIds.add(executionHostId) + const status = lastVerifiedRuntimeStatus(entry) + if (status) { + void loadHostStructuredAgents(executionHostId, status.capabilities, status.runtimeId) + } + } + retainHostStructuredAgents(hostIds) +} + +/** Reads each host's registered agents once its status says it publishes them, and again when a + * paired host's runtime changes. Returns the unsubscribe. */ +export function installHostStructuredAgentsSync(): () => void { + void ensureLocalRuntimeCapabilities().then((capabilities) => + loadHostStructuredAgents(LOCAL_EXECUTION_HOST_ID, capabilities, null) + ) + syncPairedHosts(useAppStore.getState().runtimeStatusByEnvironmentId) + return useAppStore.subscribe((state, previousState) => { + if (state.runtimeStatusByEnvironmentId !== previousState.runtimeStatusByEnvironmentId) { + syncPairedHosts(state.runtimeStatusByEnvironmentId) + } + }) +} diff --git a/src/renderer/src/runtime/host-structured-agents.test.ts b/src/renderer/src/runtime/host-structured-agents.test.ts new file mode 100644 index 000000000000..b4cbef30acd6 --- /dev/null +++ b/src/renderer/src/runtime/host-structured-agents.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../shared/electron-remote-runtime-client-capabilities' +import { + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' +import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' +import type { RuntimeCapability } from '../../../shared/protocol-version' + +type Statuses = Map +type StoreState = { runtimeStatusByEnvironmentId: Statuses } + +const mocks = vi.hoisted(() => { + const state: StoreState = { runtimeStatusByEnvironmentId: new Map() } + return { + callRuntimeRpc: vi.fn(), + ensureLocalRuntimeCapabilities: vi.fn(), + state, + listeners: new Set<(state: StoreState, previous: StoreState) => void>() + } +}) + +vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) +vi.mock('./local-runtime-capabilities', () => ({ + ensureLocalRuntimeCapabilities: mocks.ensureLocalRuntimeCapabilities +})) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mocks.state, + subscribe: (listener: (state: StoreState, previous: StoreState) => void) => { + mocks.listeners.add(listener) + return () => mocks.listeners.delete(listener) + } + } +})) + +import { + readHostStructuredAgents, + resetHostStructuredAgentsForTests +} from './host-structured-agents' +import { installHostStructuredAgentsSync } from './host-structured-agents-sync' + +const REGISTERED: RuntimeCapability[] = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY +] +const GROK_LIST = { + agents: [ + { agent: 'claude', capabilities: { imagePrompts: true } }, + { agent: 'grok', capabilities: { contextUsage: true, steering: 'queue' } } + ] +} + +function pairedStatus( + runtimeId: string, + capabilities: RuntimeCapability[] +): RuntimeEnvironmentStatus { + return { + status: { + runtimeId, + capabilities, + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0 + }, + checkedAt: 1 + } +} + +function setStatuses(statuses: Statuses): void { + const previous = mocks.state + mocks.state = { runtimeStatusByEnvironmentId: statuses } + mocks.listeners.forEach((listener) => listener(mocks.state, previous)) +} + +const agentIds = (hostId: string): string[] | undefined => + readHostStructuredAgents(hostId, mocks.state.runtimeStatusByEnvironmentId)?.map( + (row) => row.agent + ) + +let uninstall: (() => void) | undefined + +beforeEach(() => { + resetHostStructuredAgentsForTests() + mocks.state = { runtimeStatusByEnvironmentId: new Map() } + mocks.listeners.clear() + mocks.callRuntimeRpc.mockReset().mockResolvedValue(GROK_LIST) + mocks.ensureLocalRuntimeCapabilities.mockReset().mockResolvedValue(REGISTERED) +}) + +afterEach(() => { + uninstall?.() + uninstall = undefined +}) + +describe('host structured agents', () => { + // The capability promises a renderer that reads the list; it ships only with that reader. + it('is advertised to paired hosts, whose lists this reader serves', () => { + expect(ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES).toContain( + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) + }) + + it("reads the local host's agents once it advertises them", async () => { + uninstall = installHostStructuredAgentsSync() + await vi.waitFor(() => expect(agentIds('local')).toEqual(['claude', 'grok'])) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'agentSession.agents', + {}, + {} + ) + }) + + it('never asks a host that does not advertise registered agents', async () => { + mocks.ensureLocalRuntimeCapabilities.mockResolvedValue([ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) + uninstall = installHostStructuredAgentsSync() + setStatuses( + new Map([['env-1', pairedStatus('rt-1', [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY])]]) + ) + await Promise.resolve() + await Promise.resolve() + + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + expect(agentIds('local')).toBeUndefined() + expect(agentIds('runtime:env-1')).toBeUndefined() + }) + + it("reads a paired host's agents per runtime, and a restarted host is unlearned until it answers", async () => { + mocks.ensureLocalRuntimeCapabilities.mockResolvedValue(null) + uninstall = installHostStructuredAgentsSync() + setStatuses(new Map([['env-1', pairedStatus('rt-1', REGISTERED)]])) + await vi.waitFor(() => expect(agentIds('runtime:env-1')).toEqual(['claude', 'grok'])) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'agentSession.agents', + {}, + { expectedEnvironmentRuntimeId: 'rt-1' } + ) + + let answer!: (value: unknown) => void + mocks.callRuntimeRpc.mockReturnValueOnce(new Promise((resolve) => (answer = resolve))) + setStatuses(new Map([['env-1', pairedStatus('rt-2', REGISTERED)]])) + expect(agentIds('runtime:env-1')).toBeUndefined() + + answer({ agents: [{ agent: 'claude', capabilities: {} }] }) + await vi.waitFor(() => expect(agentIds('runtime:env-1')).toEqual(['claude'])) + }) + + it('leaves a host unlearned when its reply is not an agent list', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ unexpected: true }) + uninstall = installHostStructuredAgentsSync() + await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled()) + await Promise.resolve() + + expect(agentIds('local')).toBeUndefined() + }) +}) diff --git a/src/renderer/src/runtime/host-structured-agents.ts b/src/renderer/src/runtime/host-structured-agents.ts new file mode 100644 index 000000000000..4196adff0336 --- /dev/null +++ b/src/renderer/src/runtime/host-structured-agents.ts @@ -0,0 +1,145 @@ +import { + AGENT_SESSION_AGENTS_METHOD, + decodeAgentSessionAgentsResult, + type AgentSessionRegisteredAgent +} from '../../../shared/agent-session-registered-agents' +import { parseExecutionHostId } from '../../../shared/execution-host' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' +import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' +import { callRuntimeRpc } from './runtime-rpc-client' + +/** + * The structured agents each host registered, as `agentSession.agents` listed them: the one cache + * every renderer reader asks. Only a host advertising the registered-agents capability is asked; + * a host that was not, or has not answered, offers only the agents every build ships. + * + * Keyed by execution host id and stamped with the runtime instance that answered, so a host that + * restarted (and may register different agents) reads as unlearned until it answers again. + */ + +type HostAgentsEntry = { + /** The paired host's runtime id when it answered; null for the local runtime, which lives as + * long as this renderer's main process. */ + runtimeId: string | null + agents: readonly AgentSessionRegisteredAgent[] +} + +export type HostStructuredAgentsStatuses = ReadonlyMap | undefined + +const entries = new Map() +const inflight = new Map>() +const listeners = new Set<() => void>() + +function publish(): void { + listeners.forEach((listener) => listener()) +} + +/** The runtime instance a host's list must come from: null locally, undefined when unknown. */ +function currentRuntimeId( + executionHostId: string, + statuses: HostStructuredAgentsStatuses +): string | null | undefined { + const host = parseExecutionHostId(executionHostId) + if (host?.kind === 'local') { + return null + } + if (host?.kind !== 'runtime') { + return undefined + } + return lastVerifiedRuntimeStatus(statuses?.get(host.environmentId))?.runtimeId +} + +/** The agents a host's given runtime listed; undefined when that runtime has not answered. */ +export function readHostStructuredAgentsForRuntime( + executionHostId: string, + runtimeId: string | null | undefined +): readonly AgentSessionRegisteredAgent[] | undefined { + const entry = entries.get(executionHostId) + return entry && runtimeId !== undefined && entry.runtimeId === runtimeId + ? entry.agents + : undefined +} + +/** The agents this host listed, or undefined when none were learned from its current runtime. */ +export function readHostStructuredAgents( + executionHostId: string, + statuses: HostStructuredAgentsStatuses +): readonly AgentSessionRegisteredAgent[] | undefined { + return readHostStructuredAgentsForRuntime( + executionHostId, + currentRuntimeId(executionHostId, statuses) + ) +} + +/** Asks a host for its agents unless its current runtime already answered. Never rejects: a + * failed read leaves the host unlearned, and the next status change asks again. */ +export function loadHostStructuredAgents( + executionHostId: string, + hostCapabilities: readonly string[] | null | undefined, + runtimeId: string | null +): Promise { + const host = parseExecutionHostId(executionHostId) + if ( + (host?.kind !== 'local' && host?.kind !== 'runtime') || + !hostCapabilities?.includes(STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY) || + entries.get(executionHostId)?.runtimeId === runtimeId + ) { + return Promise.resolve() + } + const key = `${executionHostId}\u0000${runtimeId ?? ''}` + const pending = inflight.get(key) + if (pending) { + return pending + } + const read = callRuntimeRpc( + host.kind === 'local' + ? { kind: 'local' } + : { kind: 'environment', environmentId: host.environmentId }, + AGENT_SESSION_AGENTS_METHOD, + {}, + runtimeId === null ? {} : { expectedEnvironmentRuntimeId: runtimeId } + ) + .then((result) => { + const agents = decodeAgentSessionAgentsResult(result) + if (agents) { + entries.set(executionHostId, { runtimeId, agents }) + publish() + } + }) + .catch((error: unknown) => { + console.warn('[native-chat] could not read the host structured agents', error) + }) + .finally(() => { + inflight.delete(key) + }) + inflight.set(key, read) + return read +} + +/** Drops hosts no longer paired; a stale runtime is already ignored by the reader. */ +export function retainHostStructuredAgents(executionHostIds: ReadonlySet): void { + let removed = false + for (const id of entries.keys()) { + if (!executionHostIds.has(id)) { + entries.delete(id) + removed = true + } + } + if (removed) { + publish() + } +} + +export function subscribeHostStructuredAgents(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function resetHostStructuredAgentsForTests(): void { + entries.clear() + inflight.clear() + publish() +} diff --git a/src/renderer/src/runtime/use-host-structured-agent.ts b/src/renderer/src/runtime/use-host-structured-agent.ts new file mode 100644 index 000000000000..adb6a983f618 --- /dev/null +++ b/src/renderer/src/runtime/use-host-structured-agent.ts @@ -0,0 +1,50 @@ +import { useCallback, useSyncExternalStore } from 'react' +import type { AgentSessionRegisteredAgent } from '../../../shared/agent-session-registered-agents' +import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' +import { useAppStore } from '@/store' +import { + readHostStructuredAgentsForRuntime, + subscribeHostStructuredAgents +} from './host-structured-agents' +import type { RuntimeClientTarget } from './runtime-client-target' + +/** Whether a chat may carry images: what its host registered for the agent, else what every build + * has always sent the agents it ships. An unlisted agent claims nothing. */ +export function structuredAgentAcceptsImages( + record: AgentSessionRegisteredAgent | undefined, + agent: string +): boolean { + return record ? record.capabilities.imagePrompts : isAgentSessionHandleProvider(agent) +} + +/** The agent's record as the chat's host listed it; undefined until (or unless) it has. */ +export function useHostStructuredAgent( + target: RuntimeClientTarget, + agent: string +): AgentSessionRegisteredAgent | undefined { + const environmentId = target.kind === 'environment' ? target.environmentId : null + const runtimeId = useAppStore((state) => + environmentId === null + ? null + : lastVerifiedRuntimeStatus(state.runtimeStatusByEnvironmentId.get(environmentId))?.runtimeId + ) + const executionHostId = + environmentId === null ? LOCAL_EXECUTION_HOST_ID : toRuntimeExecutionHostId(environmentId) + const read = useCallback( + () => + readHostStructuredAgentsForRuntime(executionHostId, runtimeId)?.find( + (row) => row.agent === agent + ), + [agent, executionHostId, runtimeId] + ) + return useSyncExternalStore(subscribeHostStructuredAgents, read, read) +} + +export function useStructuredAgentAcceptsImages( + target: RuntimeClientTarget, + agent: string +): boolean { + return structuredAgentAcceptsImages(useHostStructuredAgent(target, agent), agent) +} diff --git a/src/shared/electron-remote-runtime-client-capabilities.ts b/src/shared/electron-remote-runtime-client-capabilities.ts index 47ab6f75bc43..17bdebceb79c 100644 --- a/src/shared/electron-remote-runtime-client-capabilities.ts +++ b/src/shared/electron-remote-runtime-client-capabilities.ts @@ -7,6 +7,7 @@ import { NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY } from './protocol-version' @@ -20,6 +21,8 @@ export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ // The renderer that reads a paired host's structured chats is the one that reads its own. STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + // Same renderer: it reads each host's `agentSession.agents` and renders any agent listed there. + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, // The desktop picks each launch's mode from its own settings before asking the host. STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, diff --git a/src/shared/structured-agent-session-create.ts b/src/shared/structured-agent-session-create.ts index 28d5fe8d7663..1ca1dd7f6274 100644 --- a/src/shared/structured-agent-session-create.ts +++ b/src/shared/structured-agent-session-create.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from './agent-session-provider-handle' +import type { StructuredAgentId } from './agent-session-provider-handle' import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { createStructuredAgentSessionOperationId, @@ -21,7 +21,7 @@ export type StructuredAgentSessionResumeSource = { export type StructuredAgentSessionCreateParams = { envelope: AgentSessionMutationEnvelope worktree: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId resumeFrom?: StructuredAgentSessionResumeSource /** Sent only to a host advertising `AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY`. */ tabId?: string @@ -30,7 +30,7 @@ export type StructuredAgentSessionCreateParams = { /** Provider-prefixed so a session id names its lane on sight, and underscore-only * so the id stays a single token everywhere it is embedded (tab ids, log keys). */ export function createStructuredAgentSessionId( - agent: AgentSessionHandleProvider, + agent: StructuredAgentId, randomUuid: () => string ): string { return `${agent}_${randomUuid().replaceAll('-', '_')}` @@ -52,7 +52,7 @@ export function isStructuredAgentSessionIdFor(agent: string, sessionId: string): export function structuredAgentSessionCreateParams(args: { sessionId: string worktree: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId resumeFrom?: StructuredAgentSessionResumeSource tabId?: string randomUuid: () => string diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index 16f6f4f45831..d075af5d28bc 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -1,7 +1,7 @@ import { sha256 } from './sha256' import { isAgentSessionHandleProvider, - type AgentSessionHandleProvider + type StructuredAgentId } from './agent-session-provider-handle' function canonicalize(value: unknown): string { @@ -45,7 +45,7 @@ export function structuredAgentSessionDomainFingerprint(input: { export function structuredAgentSessionCreateFingerprint(input: { sessionId: string worktree: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId resumeFrom?: { providerSessionId: string } tabId?: string }): string { From 7d6bdf9470b1ef7241a3dfc1c894053facd609a2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:14:46 -0700 Subject: [PATCH 088/269] test(native-chat): stored definitions admit Grok beside the unchanged Claude and Codex storage; ACP lane reaches no Electron import --- .../acp/acp-structured-electron-free.test.ts | 33 +++++++++++++++++++ ...tructured-agent-stored-definitions.test.ts | 10 +++++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/main/acp/acp-structured-electron-free.test.ts diff --git a/src/main/acp/acp-structured-electron-free.test.ts b/src/main/acp/acp-structured-electron-free.test.ts new file mode 100644 index 000000000000..fbebb5d625bd --- /dev/null +++ b/src/main/acp/acp-structured-electron-free.test.ts @@ -0,0 +1,33 @@ +// The ACP lane runs in whatever process hosts the runtime, the headless one included, so nothing +// it reaches may import Electron. + +import { resolve } from 'node:path' +import { build } from 'esbuild' +import { describe, expect, it } from 'vitest' + +describe('ACP structured lane', () => { + it('reaches no Electron import from the adapter or its launch resolution', async () => { + const result = await build({ + entryPoints: [ + resolve(__dirname, 'acp-structured-session-adapter.ts'), + resolve(__dirname, 'acp-structured-launch-resolution.ts'), + resolve(__dirname, 'acp-structured-child.ts') + ], + bundle: true, + write: false, + outdir: 'out', + platform: 'node', + format: 'esm', + target: 'node22', + packages: 'external', + metafile: true, + logLevel: 'silent' + }) + const imports = Object.values(result.metafile.outputs).flatMap((output) => + output.imports.map((entry) => entry.path) + ) + expect(imports.filter((path) => path === 'electron' || path.startsWith('electron/'))).toEqual( + [] + ) + }, 60_000) +}) diff --git a/src/main/runtime/structured-agent-stored-definitions.test.ts b/src/main/runtime/structured-agent-stored-definitions.test.ts index c3e7fe0927d1..9c62a4551667 100644 --- a/src/main/runtime/structured-agent-stored-definitions.test.ts +++ b/src/main/runtime/structured-agent-stored-definitions.test.ts @@ -23,6 +23,14 @@ describe('what the shipped definitions let a record store', () => { expect([...STRUCTURED_AGENT_STORAGE.keys()]).toEqual( STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) ) - expect(STRUCTURED_AGENT_STORAGE).toEqual(CLAUDE_AND_CODEX_STORED_AGENTS) + // Claude and Codex keep the storage every older build wrote; Grok's is new and its own. + expect(new Map([...STRUCTURED_AGENT_STORAGE].filter(([agent]) => agent !== 'grok'))).toEqual( + CLAUDE_AND_CODEX_STORED_AGENTS + ) + expect(STRUCTURED_AGENT_STORAGE.get('grok')).toEqual({ + agent: 'grok', + handleTransport: 'acp', + accountHomeVariable: 'GROK_HOME' + }) }) }) From 58e92bb4abf7820f5e890a589ccd4dba6b8461b8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:15:42 -0700 Subject: [PATCH 089/269] fix(acp): close the ACP child through the provider lifecycle's root-exit verdict The shared lifecycle now reports the root's exit apart from its descendants'; the ACP child accepts a close once the root is proven gone, as the other lanes do, and a real (non-agent) child process covers stdio, the stderr tail, a requested close, an unrequested exit and a binary that cannot start. --- src/main/acp/acp-structured-child.test.ts | 58 +++++++++++++++++++++++ src/main/acp/acp-structured-child.ts | 4 +- 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 src/main/acp/acp-structured-child.test.ts diff --git a/src/main/acp/acp-structured-child.test.ts b/src/main/acp/acp-structured-child.test.ts new file mode 100644 index 000000000000..2df3e7c1f91e --- /dev/null +++ b/src/main/acp/acp-structured-child.test.ts @@ -0,0 +1,58 @@ +// A real child process (plain Node, never an agent CLI) behind the ACP child wrapper. + +import { describe, expect, it } from 'vitest' +import { spawnAcpStructuredChild } from './acp-structured-child' + +// Echoes stdin lines back, writes to stderr, and exits once stdin closes. +const ECHO = [ + "process.stderr.write('agent log\\n')", + "process.stdin.on('data', (chunk) => process.stdout.write(chunk))", + "process.stdin.on('end', () => process.exit(0))" +].join(';') + +function launch(script: string) { + return { command: process.execPath, args: ['-e', script], cwd: process.cwd() } +} + +describe('ACP structured child', () => { + it('carries stdio, keeps the stderr tail, and proves its exit on close', async () => { + const child = spawnAcpStructuredChild(launch(ECHO)) + await child.spawned + expect(child.pid).toEqual(expect.any(Number)) + const echoed = new Promise((resolve) => + child.stdout.once('data', (chunk: Buffer) => resolve(chunk.toString('utf8'))) + ) + child.stdin.write('{"jsonrpc":"2.0"}\n') + expect(await echoed).toBe('{"jsonrpc":"2.0"}\n') + let exits = 0 + child.onExit(() => { + exits += 1 + }) + await expect(child.close()).resolves.toBe(true) + expect(child.exited).toBe(true) + expect(exits).toBe(1) + expect(child.stderrTail()).toContain('agent log') + await expect(child.close()).resolves.toBe(true) + }, 20_000) + + it('reports an exit nobody asked for', async () => { + const child = spawnAcpStructuredChild(launch('process.exit(3)')) + await new Promise((resolve) => child.onExit(resolve)) + expect(child.exited).toBe(true) + }, 20_000) + + // POSIX starts a supervisor that then fails to exec; Windows starts nothing at all. + it('ends a binary that cannot start, with or without a process to name', async () => { + const child = spawnAcpStructuredChild({ + command: '/nonexistent/orca-acp-test-binary', + args: [], + cwd: process.cwd() + }) + await child.spawned + if (child.pid !== undefined) { + await new Promise((resolve) => child.onExit(resolve)) + expect(child.exited).toBe(true) + } + await expect(child.close()).resolves.toBe(true) + }, 20_000) +}) diff --git a/src/main/acp/acp-structured-child.ts b/src/main/acp/acp-structured-child.ts index 9577f7e8f838..f41437bcbaa6 100644 --- a/src/main/acp/acp-structured-child.ts +++ b/src/main/acp/acp-structured-child.ts @@ -37,7 +37,7 @@ export function spawnAcpStructuredChild( const managed = spawnManagedProviderProcess(launch, { spawnImpl, site: 'acp-agent-teardown', - closeEventIsExit: true, + acceptClose: (result) => result.root === 'exited', policy: (supervised) => ({ gracefulExitMs: supervised ? PROVIDER_SUPERVISOR_MAX_STOP_MS : GRACEFUL_EXIT_MS, forcedExitMs: FORCED_EXIT_MS @@ -70,6 +70,6 @@ export function spawnAcpStructuredChild( spawned, onExit: (listener) => managed.onExit(() => listener()), stderrTail: () => stderr.trim(), - close: async () => managed.rootVerdict === 'exited' || (await managed.close()) === 'exited' + close: async () => managed.rootVerdict === 'exited' || (await managed.close()).root === 'exited' } } From b52d9554b71a7e522d7c6d7c1b6b3fe94d7d7ade Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:17:21 -0700 Subject: [PATCH 090/269] fix(acp): a resume never journals the agent's replay of history the chat already holds The host binds the journal only after acquire returns, so replay mode saw an empty journal and would adopt the whole conversation again. An ACP chat loads only a session it created, so everything the agent sends before answering session/load is dropped. --- src/main/acp/acp-structured-acquire.ts | 20 +++++------ .../acp-structured-session-adapter.test.ts | 34 ++++++++++++++++++- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 935b173dcf41..bfe8349b5b68 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -81,7 +81,11 @@ export async function acquireAcpStructuredSession(input: { const options = new AcpStructuredOptions() const prompts = new AcpStructuredPrompts(() => lane) const early: (() => void)[] = [] + let replaying = false const whenLane = (deliver: () => void): void => { + if (replaying) { + return + } if (lane) { deliver() } else if (early.length < MAX_EARLY_FRAMES) { @@ -155,14 +159,10 @@ export async function acquireAcpStructuredSession(input: { let liveLane: AcpStructuredLane | null = null let supersedesKey: string | undefined if (resume) { - const loading = makeLane(resume.sessionId) - liveLane = loading - const replays = initialized.agentCapabilities?.loadSession === true - if (replays) { - // The translator recognises replayed history against what the journal already holds. - await events?.written?.() - loading.translator.beginLoad() - } + liveLane = makeLane(resume.sessionId) + // A chat loads only a session it created, whose history its journal already holds, so what + // the agent replays before answering the load is dropped, never journaled again. + replaying = true try { started = await runtime.start({ cwd: launch.cwd, @@ -177,9 +177,7 @@ export async function acquireAcpStructuredSession(input: { // A session this chat created and the agent never saved: a new one takes its place. supersedesKey = resume.replaceableKey } finally { - if (replays) { - loading.apply(loading.translator.finishLoad(now())) - } + replaying = false } } if (!started || !liveLane) { diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 2a2dafb079dc..5e1dcc34b73f 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -71,13 +71,45 @@ describe('ACP structured session adapter: acquire', () => { }) it('loads the session this chat proved, replaying nothing over a journal that holds it', async () => { + // Unmarked, as an agent that does not flag its replay sends it. + const replayed = (update: Record) => ({ + sessionId: PROVIDER_SESSION, + update, + _meta: { promptId: 'old-prompt' } + }) const rig = await openAcpAdapterRig({ - launch: { resume: { sessionId: PROVIDER_SESSION, replaceableKey: null } } + launch: { resume: { sessionId: PROVIDER_SESSION, replaceableKey: null } }, + script: (agent) => + agent.on('session/load', (frame) => { + agent.notify( + 'session/update', + replayed({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'hi' } }) + ) + agent.notify( + 'session/update', + replayed({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'yo' } + }) + ) + agent.notify( + 'session/update', + replayed({ + sessionUpdate: 'tool_call', + toolCallId: 'call-1', + title: 'ls', + status: 'completed' + }) + ) + agent.reply(frame, { configOptions: [] }) + }) }) const acquired = await rig.acquire({ fence: 2 }) expect(rig.sent('session/load')[0]?.params).toMatchObject({ sessionId: PROVIDER_SESSION }) expect(rig.sent('session/new')).toHaveLength(0) expect(acquired.link).toMatchObject({ origin: 'resumed', mintedAtFence: 2 }) + await rig.settle() + expect(await rig.rig.rows()).toEqual([]) }) it('replaces a created session the agent never saved with a new one', async () => { From 135f4e9fed527223b1560eb2dbe4100a7ac3ffcd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:17:31 -0700 Subject: [PATCH 091/269] chore(acp): drop an unused initialize result --- src/main/acp/acp-structured-acquire.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index bfe8349b5b68..f3f9e803711d 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -153,7 +153,7 @@ export async function acquireAcpStructuredSession(input: { } await identity.onSpawned(child.pid) try { - const initialized = await runtime.initialize() + await runtime.initialize() const resume = launch.resume let started: Awaited> | null = null let liveLane: AcpStructuredLane | null = null From df87dac3920dda7ac3e1c6f105f3e9768e39618d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:18:21 -0700 Subject: [PATCH 092/269] fix(acp): an agent that exits while starting fails its start with its own last words The existing start-failure surface already says the agent stopped before it finished starting when the adapter marks the exit observed; the ACP lane now does, with the agent's stderr tail as the detail. --- .../acp-structured-session-adapter.test.ts | 20 ++++++++++++++++++- .../acp/acp-structured-session-adapter.ts | 14 ++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 5e1dcc34b73f..12e044a9f619 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -1,7 +1,10 @@ import { afterEach, describe, expect, it } from 'vitest' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + AgentSessionAcquisitionExitProvenError, + AgentSessionAcquisitionRefusal +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { ACP_CHILD_ENV_TO_DELETE } from './acp-launch-specs' import { GROK, @@ -134,6 +137,21 @@ describe('ACP structured session adapter: acquire', () => { }) }) +it('reports an agent that exits while starting with its own last words', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/new', () => { + const child = rig.child() + child.stderr = 'grok: config.toml is invalid' + child.exit() + }) + }) + const failure = await rig.acquire().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AgentSessionAcquisitionExitProvenError) + expect(failure).toMatchObject({ message: 'grok: config.toml is invalid' }) + expect(rig.lifecycle).toEqual([]) +}) + describe('ACP structured session adapter: turns', () => { it('sends a prompt under an injected id and settles it on the agent first event', async () => { const rig = await openAcpAdapterRig() diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index ab894cfe5d70..e4ac7b241655 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -8,7 +8,9 @@ import { agentSessionFailureWords } from '../../shared/agent-session-failure-wor import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { + AgentSessionAcquisitionExitProvenError, AgentSessionAcquisitionExitUnprovenError, + isAgentSessionPreSpawnError, type AgentSessionAcquisition, type AgentSessionDispatchOutcome, type StructuredAgentSessionAcquireInput, @@ -16,6 +18,7 @@ import { type StructuredAgentSessionSetOptionInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' +import { withObservedProviderExit } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { acpAgentName, acquireAcpStructuredSession } from './acp-structured-acquire' import { AcpRequestTimeoutError } from './acp-errors' import type { AcpStructuredChild } from './acp-structured-child' @@ -65,9 +68,18 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte this.sessions.set(sessionId, session) return acquisition } catch (error) { - if (tracked.child && !(await tracked.child.close().catch(() => false))) { + const child = tracked.child + // Checked before the close below, which would make any exit look like one Orca asked for. + const exitedOnItsOwn = child?.exited === true + if (child && !(await child.close().catch(() => false))) { throw new AgentSessionAcquisitionExitUnprovenError(error) } + if (child && exitedOnItsOwn && !isAgentSessionPreSpawnError(error)) { + // The agent's own last words are what a person can act on. + throw new AgentSessionAcquisitionExitProvenError( + withObservedProviderExit(new Error(child.stderrTail() || String(error), { cause: error })) + ) + } throw error } finally { if (this.starting.get(sessionId) === tracked.child) { From 36dd8a56105cd00d40ca52b115517ce6f5f5277d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:21:13 -0700 Subject: [PATCH 093/269] fix(native-chat): read a host's registered agents only once structured chat is in use Asking a host for its agents installs its structured store, which a profile with the setting off never needed; the read now waits for the setting (or chats the runtime already holds) and runs again when it is turned on. An agent-launch test that used Grok as its terminal-only example now uses Gemini. --- .../runtime/rpc/methods/agent-launch.test.ts | 4 +- .../runtime/host-structured-agents-sync.ts | 42 +++++++++++++++---- .../runtime/host-structured-agents.test.ts | 33 +++++++++++++-- 3 files changed, 66 insertions(+), 13 deletions(-) diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index 75045cf74c48..bcf3234f3c77 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -491,7 +491,7 @@ describe('the terminal factory', () => { it('takes an existing workspace without creating one', async () => { const runtime = runtimeStub() const result = await launch( - { agent: 'grok', target: { kind: 'existing', worktree: 'id:wt-7' } }, + { agent: 'gemini', target: { kind: 'existing', worktree: 'id:wt-7' } }, runtime ) @@ -502,7 +502,7 @@ describe('the terminal factory', () => { // Resolved to an id first: everything below re-prefixes it, so a raw selector reaches the // runtime as `id:id:wt-7`. expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-7', { - startupAgent: 'grok', + startupAgent: 'gemini', onPtySpawnDispatched: expect.any(Function) }) expect(result.worktreeId).toBe('wt-7') diff --git a/src/renderer/src/runtime/host-structured-agents-sync.ts b/src/renderer/src/runtime/host-structured-agents-sync.ts index a4e9a1161ca2..ed181fdff3f8 100644 --- a/src/renderer/src/runtime/host-structured-agents-sync.ts +++ b/src/renderer/src/runtime/host-structured-agents-sync.ts @@ -4,6 +4,24 @@ import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { loadHostStructuredAgents, retainHostStructuredAgents } from './host-structured-agents' import { ensureLocalRuntimeCapabilities } from './local-runtime-capabilities' +import { localStructuredChatsInUse } from './local-structured-chats' + +// Why gated: asking a host for its agents installs its structured host, which a profile that never +// uses structured chat must not pay for; only with chats in use can a launch route to one. +function structuredChatSettingOn(state: Pick): boolean { + return state.settings?.experimentalStructuredNativeChat === true +} + +async function syncLocalHost(settings: AppState['settings']): Promise { + if (!(await localStructuredChatsInUse(settings))) { + return + } + await loadHostStructuredAgents( + LOCAL_EXECUTION_HOST_ID, + await ensureLocalRuntimeCapabilities(), + null + ) +} function syncPairedHosts(statuses: AppState['runtimeStatusByEnvironmentId']): void { const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) @@ -18,15 +36,25 @@ function syncPairedHosts(statuses: AppState['runtimeStatusByEnvironmentId']): vo retainHostStructuredAgents(hostIds) } -/** Reads each host's registered agents once its status says it publishes them, and again when a - * paired host's runtime changes. Returns the unsubscribe. */ +/** Reads each host's registered agents once its status says it publishes them and structured chat + * is in use, and again when a paired host's runtime changes. Returns the unsubscribe. */ export function installHostStructuredAgentsSync(): () => void { - void ensureLocalRuntimeCapabilities().then((capabilities) => - loadHostStructuredAgents(LOCAL_EXECUTION_HOST_ID, capabilities, null) - ) - syncPairedHosts(useAppStore.getState().runtimeStatusByEnvironmentId) + const initial = useAppStore.getState() + void syncLocalHost(initial.settings) + if (structuredChatSettingOn(initial)) { + syncPairedHosts(initial.runtimeStatusByEnvironmentId) + } return useAppStore.subscribe((state, previousState) => { - if (state.runtimeStatusByEnvironmentId !== previousState.runtimeStatusByEnvironmentId) { + const on = structuredChatSettingOn(state) + const turnedOn = on && !structuredChatSettingOn(previousState) + if (turnedOn) { + void syncLocalHost(state.settings) + } + if ( + on && + (turnedOn || + state.runtimeStatusByEnvironmentId !== previousState.runtimeStatusByEnvironmentId) + ) { syncPairedHosts(state.runtimeStatusByEnvironmentId) } }) diff --git a/src/renderer/src/runtime/host-structured-agents.test.ts b/src/renderer/src/runtime/host-structured-agents.test.ts index b4cbef30acd6..41166e2c0e98 100644 --- a/src/renderer/src/runtime/host-structured-agents.test.ts +++ b/src/renderer/src/runtime/host-structured-agents.test.ts @@ -8,10 +8,15 @@ import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-stat import type { RuntimeCapability } from '../../../shared/protocol-version' type Statuses = Map -type StoreState = { runtimeStatusByEnvironmentId: Statuses } +type StoreState = { + runtimeStatusByEnvironmentId: Statuses + settings: { experimentalStructuredNativeChat: boolean } | null +} + +const ON = { experimentalStructuredNativeChat: true } const mocks = vi.hoisted(() => { - const state: StoreState = { runtimeStatusByEnvironmentId: new Map() } + const state: StoreState = { runtimeStatusByEnvironmentId: new Map(), settings: null } return { callRuntimeRpc: vi.fn(), ensureLocalRuntimeCapabilities: vi.fn(), @@ -24,6 +29,10 @@ vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc }) vi.mock('./local-runtime-capabilities', () => ({ ensureLocalRuntimeCapabilities: mocks.ensureLocalRuntimeCapabilities })) +vi.mock('./local-structured-chats', () => ({ + localStructuredChatsInUse: async (settings: StoreState['settings']) => + settings?.experimentalStructuredNativeChat === true +})) vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state, @@ -71,7 +80,7 @@ function pairedStatus( function setStatuses(statuses: Statuses): void { const previous = mocks.state - mocks.state = { runtimeStatusByEnvironmentId: statuses } + mocks.state = { ...previous, runtimeStatusByEnvironmentId: statuses } mocks.listeners.forEach((listener) => listener(mocks.state, previous)) } @@ -84,7 +93,7 @@ let uninstall: (() => void) | undefined beforeEach(() => { resetHostStructuredAgentsForTests() - mocks.state = { runtimeStatusByEnvironmentId: new Map() } + mocks.state = { runtimeStatusByEnvironmentId: new Map(), settings: ON } mocks.listeners.clear() mocks.callRuntimeRpc.mockReset().mockResolvedValue(GROK_LIST) mocks.ensureLocalRuntimeCapabilities.mockReset().mockResolvedValue(REGISTERED) @@ -151,6 +160,22 @@ describe('host structured agents', () => { await vi.waitFor(() => expect(agentIds('runtime:env-1')).toEqual(['claude'])) }) + // Asking installs a host's structured store; a profile with structured chat off never pays it. + it('asks no host until structured chat is turned on', async () => { + mocks.state = { ...mocks.state, settings: { experimentalStructuredNativeChat: false } } + uninstall = installHostStructuredAgentsSync() + setStatuses(new Map([['env-1', pairedStatus('rt-1', REGISTERED)]])) + await Promise.resolve() + await Promise.resolve() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + + const previous = mocks.state + mocks.state = { ...previous, settings: ON } + mocks.listeners.forEach((listener) => listener(mocks.state, previous)) + await vi.waitFor(() => expect(agentIds('local')).toEqual(['claude', 'grok'])) + await vi.waitFor(() => expect(agentIds('runtime:env-1')).toEqual(['claude', 'grok'])) + }) + it('leaves a host unlearned when its reply is not an agent list', async () => { mocks.callRuntimeRpc.mockResolvedValue({ unexpected: true }) uninstall = installHostStructuredAgentsSync() From e835067a8811f2b32947c9cb1e6ce48e318eea65 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:33:34 -0700 Subject: [PATCH 094/269] chore(native-chat): keep the host under its line limit after the stacked merges; merge duplicate imports --- src/main/acp/acp-structured-session-adapter-deps.ts | 6 ++++-- src/main/acp/acp-structured-session-adapter.test.ts | 6 ++++-- .../agent-session-wire/structured-agent-session-host.ts | 9 +++------ 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/main/acp/acp-structured-session-adapter-deps.ts b/src/main/acp/acp-structured-session-adapter-deps.ts index 416bfbfde813..c293782aa74a 100644 --- a/src/main/acp/acp-structured-session-adapter-deps.ts +++ b/src/main/acp/acp-structured-session-adapter-deps.ts @@ -1,11 +1,13 @@ import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import type { AcpLaunchSpec } from './acp-launch-specs' import type { SpawnAcpStructuredChild } from './acp-structured-child' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' -import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' export type AcpStructuredSessionAdapterDeps = { spec: AcpLaunchSpec diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 12e044a9f619..c469364a8767 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' -import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { AgentSessionAcquisitionExitProvenError, AgentSessionAcquisitionRefusal @@ -14,7 +17,6 @@ import { waitFor, type AcpAdapterRig } from './acp-structured-adapter.test-support' -import { SESSION } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' afterEach(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index b97e67c39225..eb59f1969dfa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -1,5 +1,4 @@ import type { AgentSessionRewindParams } from '../../../shared/agent-session-rewind' -import type { StructuredAgentDefinition } from './structured-agent-definition' import { rewindStructuredAgentSession } from './structured-agent-session-rewind' import { StructuredConversationCommandController } from './structured-conversation-command-controller' // Structured agent-session host: where the lease, journal, and provider adapter meet. @@ -221,8 +220,7 @@ export class StructuredAgentSessionHost { /** What this runtime registered: one agent's definition (null when unregistered), or all of them. */ agentDefinition = (agent: string) => this.deps.adapter.definition?.(agent) ?? null - agentDefinitions = (): readonly StructuredAgentDefinition[] => - this.deps.adapter.definitions?.() ?? [] + agentDefinitions = () => this.deps.adapter.definitions?.() ?? [] private readonly tabs = sessionTabs.createStructuredAgentSessionTabSurface( this, @@ -262,11 +260,10 @@ export class StructuredAgentSessionHost { flushStreamedEvents = (sessionId: string): Promise => this.runtimeState.flushEventSink(sessionId) - // Trigger inlined rather than imported: `AgentSessionResumeTrigger` in shared is the canonical - // type, and this file has no line budget left for the import. /** Quit: no exit or recovery settled after this starts a child or hands a message over. */ stopDelivery = (): void => this.conversationDelivery.dispose() - + // Trigger inlined rather than imported: `AgentSessionResumeTrigger` in shared is the canonical + // type, and this file has no line budget left for the import. async flushAllStreamedEvents(options?: { trigger?: 'quit' | 'update' }): Promise { this.stopDelivery() await flushStructuredAgentSessionHost({ From 2b1f6d37108305f9b6830c8f6dd9a5e2c75aaddc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:42:18 -0700 Subject: [PATCH 095/269] fix(native-chat): typecheck fallout of the stacked merges Journal-identity fixtures from the timeline branches named the pre-#24991 handle shape; they now use the opaque handle. The ACP acquire keeps its lane in a slot the closures write, and a renderer route test builds its partial store the way its siblings do. --- src/main/acp/acp-structured-acquire.ts | 18 ++++++++++-------- .../journal-provider-item-ref.test.ts | 3 ++- ...provider-timeline-assembler-test-support.ts | 2 +- .../provider-timeline-joins.test.ts | 3 ++- ...-agent-session-late-proof-tool-ends.test.ts | 3 ++- ...structured-agent-session-transition.test.ts | 2 +- ...gent-launch-route-registered-agents.test.ts | 4 +++- 7 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index f3f9e803711d..9fc3006699d2 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -77,16 +77,17 @@ export async function acquireAcpStructuredSession(input: { }) input.track(child) let session: AcpStructuredSession | null = null - let lane: AcpStructuredLane | null = null + // A slot rather than a `let`: closures read it, and control-flow narrowing cannot see them write. + const slot: { lane: AcpStructuredLane | null } = { lane: null } const options = new AcpStructuredOptions() - const prompts = new AcpStructuredPrompts(() => lane) + const prompts = new AcpStructuredPrompts(() => slot.lane) const early: (() => void)[] = [] let replaying = false const whenLane = (deliver: () => void): void => { if (replaying) { return } - if (lane) { + if (slot.lane) { deliver() } else if (early.length < MAX_EARLY_FRAMES) { early.push(deliver) @@ -109,7 +110,7 @@ export async function acquireAcpStructuredSession(input: { }, onRequest: (method, params, context) => prompts.handle(method, params, context), onExtensionNotification: (method, params) => - whenLane(() => lane?.apply(lane.translator.notification(method, params, now()))), + whenLane(() => slot.lane?.apply(slot.lane.translator.notification(method, params, now()))), onDiagnostic: (message) => deps.logger?.warn('ACP agent protocol diagnostic', { scope: 'acp-diagnostic', @@ -118,7 +119,7 @@ export async function acquireAcpStructuredSession(input: { }) }) runtime.subscribe((event: AcpSessionEvent) => - whenLane(() => lane && routeAcpSessionEvent({ lane, options }, event, now())) + whenLane(() => slot.lane && routeAcpSessionEvent({ lane: slot.lane, options }, event, now())) ) child.onExit(() => { runtime.close(new Error(child.stderrTail() || `${spec.command} exited`)) @@ -130,7 +131,7 @@ export async function acquireAcpStructuredSession(input: { deps.readProcessStartTime ) const makeLane = (providerSessionId: string): AcpStructuredLane => { - lane = new AcpStructuredLane({ + const lane = new AcpStructuredLane({ sink, sessionId, agent: spec.agent, @@ -140,6 +141,7 @@ export async function acquireAcpStructuredSession(input: { onInputAccepted: (clientMessageId) => session?.turns.accept(clientMessageId), onFailed: () => input.forceClose(sessionId) }) + slot.lane = lane for (const deliver of early.splice(0)) { deliver() } @@ -182,7 +184,7 @@ export async function acquireAcpStructuredSession(input: { } if (!started || !liveLane) { liveLane?.dispose() - lane = null + slot.lane = null started = await runtime.start({ cwd: launch.cwd, mcpServers: [] }) liveLane = makeLane(started.sessionId) } @@ -238,7 +240,7 @@ export async function acquireAcpStructuredSession(input: { return { acquisition: { process, link, acquisitionGeneration: generation }, session } } catch (error) { session = null - lane?.dispose() + slot.lane?.dispose() if (error instanceof AcpAuthRequiredError) { throw new AgentSessionAcquisitionRefusal( `${spec.agent} reported that it is not signed in: ${error.message}`, diff --git a/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts b/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts index d80d5a539965..a825ec00aa38 100644 --- a/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const journals = createTrackedJournalOpener() const roots: string[] = [] @@ -26,7 +27,7 @@ async function open(root?: string) { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'root' } + providerHandle: codexProviderHandle('root') }, stateDirectory: directory, now: () => 1_000 diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts index c7e66edb98bb..9203dc1317da 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -219,7 +219,7 @@ export async function openProviderTimelineRig( workspaceId: 'workspace-1', hostId: 'local', agent: AGENT, - providerHandle: { kind: 'opaque', agent: AGENT, value: 'provider-session-1' } + providerHandle: { transport: 'acp', agent: AGENT, nativeId: 'provider-session-1' } }, stateDirectory: root, now: () => 1_000 diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts index 6a3f1e8d9d3f..ac27bf96cdc1 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts @@ -13,6 +13,7 @@ import { createTrackedJournalOpener } from '../agent-session-journal/journal-hos import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createLegacyProviderTimelineIdentityScheme } from './provider-timeline-identity' import { ProviderTimelineJoins, type ProviderTimelineItemJoin } from './provider-timeline-joins' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const journals = createTrackedJournalOpener() const roots: string[] = [] @@ -33,7 +34,7 @@ async function openJournal(): Promise { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'root' } + providerHandle: codexProviderHandle('root') }, stateDirectory: root, now: () => 1_000 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts index 3e419d28aab8..b9093d850994 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts @@ -16,6 +16,7 @@ import { settleStaleStructuredAgentSessionState, settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'session-late-proof' const THREAD = 'thread-1' @@ -48,7 +49,7 @@ beforeEach(async () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, stateDirectory: root, now: () => 100 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts index 1b242dd8d983..1a7c96c89f86 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -58,7 +58,7 @@ async function rig(watermarks: Partial = { workspaceId: 'workspace-1', hostId: 'local', agent: 'grok', - providerHandle: { kind: 'opaque', agent: 'grok', value: 'provider-session-1' } + providerHandle: { transport: 'acp', agent: 'grok', nativeId: 'provider-session-1' } }, stateDirectory: root, now: () => 1_000 diff --git a/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts b/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts index d9b832c87cb8..0bc5918801c5 100644 --- a/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts +++ b/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts @@ -71,12 +71,14 @@ function pairedStatus(runtimeId: string): RuntimeEnvironmentStatus { function store( overrides: { settings?: Partial; runtimeId?: string } = {} ): AgentLaunchRouteStore { - return { + const state = { settings: { ...SETTINGS, ...overrides.settings }, runtimeStatusByEnvironmentId: new Map([['env-1', pairedStatus(overrides.runtimeId ?? 'rt-1')]]), worktreesByRepo: {}, folderWorkspaces: [] } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the route builder reads only the settings, host statuses and workspace rows given here; a missing member reads as absent, as the sibling route tests rely on. + return state as unknown as AgentLaunchRouteStore } const routeFor = (appStore: AgentLaunchRouteStore, agent: TuiAgent, workspace = LOCAL) => From 872faced051d1009dacdd59b582f3ff449678a76 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:58:44 -0700 Subject: [PATCH 096/269] test(native-chat): worker-start wording uses Gemini as its agent with no structured session --- .../methods/orchestration-worker-start-receipt-wording.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts index 5e84ea1fae7c..09af693e9f44 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts @@ -74,7 +74,7 @@ describe('worker-start mode receipt wording', () => { ], [ 'agent with no structured session', - { agent: 'grok' }, + { agent: 'gemini' }, 'agent_without_structured_session', 'this agent has no structured session' ] From e13b430d2e56df89789f74e6480392b480dcc911 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:04:48 -0700 Subject: [PATCH 097/269] =?UTF-8?q?fix(native-chat):=20Grok=20chat=20lifec?= =?UTF-8?q?ycle=20=E2=80=94=20named=20Stop,=20broken=20connection,=20resum?= =?UTF-8?q?e=20replay,=20unanswered=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A Stop naming a turn that has ended refuses (turnNotRunning) and touches nothing newer; a Stop with nothing of Orca's running cancels the turn Grok began itself and waits for its end. - A connection that breaks while the child may still run closes the journal (running send unknown, running turn unverifiable), stops the child, and reports `ended` only once its exit is proven. An answer Orca cannot read is treated the same way. - An agent's error answer before its turn starts rejects the send; after it starts, the turn ends as failed. Only the agent's own error answers count as either. - session/load replay is reconciled against the journal instead of dropped: settled turns are skipped, a saved reply Orca never wrote is adopted and joined to the send already there. - The handshake is bounded, and a close stops a child still starting without queueing behind its acquire (new optional adapter hook, called by the host close outside the session queue). - A chat whose child is proven gone leaves no entry in the adapter. --- src/main/acp/acp-errors.ts | 11 +- src/main/acp/acp-json-rpc-peer.ts | 9 +- src/main/acp/acp-prompt-turns.ts | 9 +- src/main/acp/acp-replay-user-messages.ts | 30 +- src/main/acp/acp-session-runtime.ts | 8 +- src/main/acp/acp-structured-acquire.ts | 66 +++- .../acp-structured-adapter.test-support.ts | 20 +- src/main/acp/acp-structured-lane.ts | 24 ++ .../acp-structured-session-adapter-deps.ts | 5 + ...ructured-session-adapter-lifecycle.test.ts | 294 ++++++++++++++++++ ...-structured-session-adapter-resume.test.ts | 127 ++++++++ .../acp-structured-session-adapter.test.ts | 42 --- .../acp/acp-structured-session-adapter.ts | 149 +++++++-- src/main/acp/acp-structured-session.ts | 37 ++- src/main/acp/acp-structured-turns.ts | 19 +- src/main/acp/acp-timeline-translator.ts | 31 +- ...structured-agent-session-adapter-router.ts | 5 + .../structured-agent-session-adapter.ts | 4 + ...agent-session-close-abandons-start.test.ts | 48 +++ ...red-agent-session-conversation-lifetime.ts | 15 +- 20 files changed, 829 insertions(+), 124 deletions(-) create mode 100644 src/main/acp/acp-structured-session-adapter-lifecycle.test.ts create mode 100644 src/main/acp/acp-structured-session-adapter-resume.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-close-abandons-start.test.ts diff --git a/src/main/acp/acp-errors.ts b/src/main/acp/acp-errors.ts index 41aff9694e2e..71b5c9d4f9a0 100644 --- a/src/main/acp/acp-errors.ts +++ b/src/main/acp/acp-errors.ts @@ -11,7 +11,16 @@ export class AcpRpcError extends Error { } } -export class AcpAuthRequiredError extends AcpRpcError { +/** The agent's own error answer to a request: it read the request and refused it. Errors Orca + * raises about a request (a timeout, an unreadable answer, a closed connection) are not this. */ +export class AcpAgentError extends AcpRpcError { + constructor(code: number, message: string, data?: unknown) { + super(code, message, data) + this.name = 'AcpAgentError' + } +} + +export class AcpAuthRequiredError extends AcpAgentError { constructor( message: string, data?: unknown, diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index 08df74ef89ae..35d5c0a7848a 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -5,7 +5,12 @@ import { createIncrementalNdjsonFramer, encodeNdjson } from '../../shared/main-process-ndjson-framer' -import { AcpConnectionClosedError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' +import { + AcpAgentError, + AcpConnectionClosedError, + AcpRequestTimeoutError, + AcpRpcError +} from './acp-errors' import { AcpIncomingRequests } from './acp-incoming-requests' import { AcpWriteQueue } from './acp-write-queue' import { detachAcpStreamErrorHandler } from './acp-stdio-error-boundary' @@ -274,7 +279,7 @@ export class AcpJsonRpcPeer { pending.reject(new AcpRpcError(-32603, 'Invalid ACP error response', frame.error)) } else { const error = parsedError.data - pending.reject(new AcpRpcError(error.code, error.message, error.data)) + pending.reject(new AcpAgentError(error.code, error.message, error.data)) } } else { pending.resolve(frame.result) diff --git a/src/main/acp/acp-prompt-turns.ts b/src/main/acp/acp-prompt-turns.ts index 6dd867688c87..dc68ba0a5f5b 100644 --- a/src/main/acp/acp-prompt-turns.ts +++ b/src/main/acp/acp-prompt-turns.ts @@ -8,6 +8,13 @@ export type AcpPromptTurn = { durationMs?: number } +const PROMPT_TURN_PREFIX = 'prompt:' + +/** The send behind a turn Orca's own prompt opened; undefined for a turn the agent began. */ +export function acpPromptClientMessageId(turn: string): string | undefined { + return turn.startsWith(PROMPT_TURN_PREFIX) ? turn.slice(PROMPT_TURN_PREFIX.length) : undefined +} + /** An injected identity is known before any provider output arrives. */ export class AcpPromptTurns { current?: AcpPromptTurn @@ -21,7 +28,7 @@ export class AcpPromptTurns { if (this.current) { throw new Error('ACP prompt overlaps a prompt or load') } - const turn = `prompt:${clientMessageId}` + const turn = `${PROMPT_TURN_PREFIX}${clientMessageId}` this.current = { clientMessageId, turn, requestedAt: at, opened: false } return { promptId: turn, events: this.injected ? [] : this.start(turn, at) } } diff --git a/src/main/acp/acp-replay-user-messages.ts b/src/main/acp/acp-replay-user-messages.ts index 189d2e6aead5..8b8aca36c145 100644 --- a/src/main/acp/acp-replay-user-messages.ts +++ b/src/main/acp/acp-replay-user-messages.ts @@ -1,8 +1,15 @@ -import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalMessageItem, + AgentJournalRenderItem +} from '../../shared/agent-session-journal-types' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' +import { acpJournalTurnHasUser } from './acp-journal-turns' +import { acpPromptClientMessageId } from './acp-prompt-turns' import type { SessionUpdate } from './generated/acp-protocol.generated' /** User text has no grammar stream channel; adopted history needs a bounded whole-body snapshot. */ @@ -41,3 +48,24 @@ export class AcpReplayUserMessages { } } } + +/** The user message of a replayed turn the journal lacks. A turn Orca's own prompt began whose send + * the journal holds takes that send as its opener, so the bubble is not written twice. */ +export function acpReplayedUser( + rows: readonly AgentJournalRenderItem[], + join: { thread: string; turn: string }, + body: AgentJournalMessageItem +): ProviderTimelineEvent[] { + if (acpJournalTurnHasUser(rows, join.turn)) { + return [] + } + const clientMessageId = acpPromptClientMessageId(join.turn) + const sent = + clientMessageId === undefined + ? undefined + : rows.find((row) => row.itemId === agentJournalSubmissionKey(clientMessageId)) + if (clientMessageId !== undefined && sent) { + return [{ type: 'input.accepted', clientMessageId, requestedAt: sent.observedAt, join }] + } + return [{ type: 'item.update', item: `replay-user:${join.turn}:`, body, join }] +} diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 7beddcba79f6..84d88b25e9b7 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -165,7 +165,9 @@ export class AcpSessionRuntime { return active.response } - cancel(): Promise { + /** `agentTurn`: no prompt of Orca's runs but a turn the agent began itself does, so the + * notification still goes out and that turn's own end is the only reply. */ + cancel(options: { agentTurn?: boolean } = {}): Promise { if (!this.started) { return Promise.reject(new Error('ACP session has not started')) } @@ -174,7 +176,9 @@ export class AcpSessionRuntime { this.peer.cancelIncomingRequests('session/request_permission') const active = this.activePrompt if (!active) { - return Promise.resolve() + return options.agentTurn + ? this.peer.notify('session/cancel', { sessionId }) + : Promise.resolve() } active.cancelling = true active.cancelPromise ??= this.cancelActive(sessionId, active) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 9fc3006699d2..cf7514a7d4f0 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -1,6 +1,7 @@ // Making a reservation real for an ACP agent: spawn the child, record it before any handshake, // initialize with the client's file system and terminals off, then load the session this chat -// proved or start a new one. +// proved or start a new one. The handshake is bounded: an agent that never answers fails the start +// instead of holding the chat's queue, and a close can stop the child at any point of it. import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' @@ -27,7 +28,10 @@ import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options' import { AcpStructuredPrompts } from './acp-structured-prompts' import { routeAcpSessionEvent, type AcpStructuredSession } from './acp-structured-session' -import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' +import { + ACP_CANCEL_TIMEOUT_MS, + type AcpStructuredSessionAdapterDeps +} from './acp-structured-session-adapter-deps' import { AcpStructuredTurns, type AcpStructuredTurnsDeps } from './acp-structured-turns' import { RequestPermissionResponseSchema } from './generated/acp-protocol.generated' @@ -35,6 +39,16 @@ import { RequestPermissionResponseSchema } from './generated/acp-protocol.genera const ACP_RESOURCE_NOT_FOUND = -32002 /** Frames an agent may send before its session exists; past this the start is refused. */ const MAX_EARLY_FRAMES = 2_048 +/** How long the agent has to answer the handshake, load or create its session, and take saved + * options back. A load replays the whole conversation, so this is generous. */ +export const ACP_STARTUP_TIMEOUT_MS = 60_000 + +export class AcpStartupTimeoutError extends Error { + constructor(agentName: string, timeoutMs: number) { + super(`${agentName} did not finish starting within ${Math.round(timeoutMs / 1_000)} seconds`) + this.name = 'AcpStartupTimeoutError' + } +} export function acpAgentName(agent: string): string { return isTuiAgent(agent) ? TUI_AGENT_DISPLAY_NAMES[agent] : agent @@ -48,6 +62,9 @@ export async function acquireAcpStructuredSession(input: { track: (child: AcpStructuredChild) => void /** The child's exit, observed while or after the session exists. */ onExit: (session: AcpStructuredSession | null) => void + /** The connection closed with the child perhaps still running. Null while starting: then the + * start itself fails. */ + onConnectionLost: (session: AcpStructuredSession | null, error: Error) => void onSettled: AcpStructuredTurnsDeps['settle'] forceClose: (sessionId: string) => void }): Promise<{ acquisition: AgentSessionAcquisition; session: AcpStructuredSession }> { @@ -82,11 +99,8 @@ export async function acquireAcpStructuredSession(input: { const options = new AcpStructuredOptions() const prompts = new AcpStructuredPrompts(() => slot.lane) const early: (() => void)[] = [] - let replaying = false + const connection = { closed: false } const whenLane = (deliver: () => void): void => { - if (replaying) { - return - } if (slot.lane) { deliver() } else if (early.length < MAX_EARLY_FRAMES) { @@ -95,7 +109,7 @@ export async function acquireAcpStructuredSession(input: { } const runtime = new AcpSessionRuntime(child.stdout, child.stdin, { clientInfo: { name: 'orca', version: '1' }, - ...(deps.cancelTimeoutMs === undefined ? {} : { cancelTimeoutMs: deps.cancelTimeoutMs }), + cancelTimeoutMs: deps.cancelTimeoutMs ?? ACP_CANCEL_TIMEOUT_MS, onPermission: (request, context) => { if (launch.fullAccess) { // Full access: Orca answers yes for the person, as the agent's own bypass flag would. @@ -116,14 +130,19 @@ export async function acquireAcpStructuredSession(input: { scope: 'acp-diagnostic', sessionId, message - }) + }), + onClose: (error) => { + connection.closed = true + input.onConnectionLost(session, error) + } }) runtime.subscribe((event: AcpSessionEvent) => whenLane(() => slot.lane && routeAcpSessionEvent({ lane: slot.lane, options }, event, now())) ) child.onExit(() => { - runtime.close(new Error(child.stderrTail() || `${spec.command} exited`)) + // The exit first, so the connection's close it causes is not read as one that broke. input.onExit(session) + runtime.close(new Error(child.stderrTail() || `${spec.command} exited`)) }) const identity = providerSpawnedProcessIdentity( acquire, @@ -154,6 +173,13 @@ export async function acquireAcpStructuredSession(input: { ) } await identity.onSpawned(child.pid) + const agentName = acpAgentName(spec.agent) + const startupTimeoutMs = deps.startupTimeoutMs ?? ACP_STARTUP_TIMEOUT_MS + // Closing the connection fails whichever request the agent left unanswered. + const startupDeadline = setTimeout( + () => runtime.close(new AcpStartupTimeoutError(agentName, startupTimeoutMs)), + startupTimeoutMs + ) try { await runtime.initialize() const resume = launch.resume @@ -162,15 +188,17 @@ export async function acquireAcpStructuredSession(input: { let supersedesKey: string | undefined if (resume) { liveLane = makeLane(resume.sessionId) - // A chat loads only a session it created, whose history its journal already holds, so what - // the agent replays before answering the load is dropped, never journaled again. - replaying = true + // What the agent replays before answering the load is reconciled against the journal, which + // stays the truth: turns it settled are skipped, and what the agent saved that Orca never + // wrote (a reply cut off by a crash) is adopted. + liveLane.translator.beginLoad() try { started = await runtime.start({ cwd: launch.cwd, mcpServers: [], sessionId: resume.sessionId }) + liveLane.apply(liveLane.translator.finishLoad(now())) } catch (error) { const notFound = error instanceof AcpRpcError && error.code === ACP_RESOURCE_NOT_FOUND if (!notFound || resume.replaceableKey === null) { @@ -178,8 +206,6 @@ export async function acquireAcpStructuredSession(input: { } // A session this chat created and the agent never saved: a new one takes its place. supersedesKey = resume.replaceableKey - } finally { - replaying = false } } if (!started || !liveLane) { @@ -214,12 +240,16 @@ export async function acquireAcpStructuredSession(input: { turns: new AcpStructuredTurns({ runtime, lane: liveLane, - agentName: acpAgentName(spec.agent), + agentName, now, - settle: input.onSettled + settle: input.onSettled, + // An answer Orca cannot read leaves the connection untrustworthy, as a broken one is. + onTransportFault: (error) => + runtime.close(error instanceof Error ? error : new Error(String(error))) }), restoreSkipped, closeRequested: false, + journalClosed: null, ended: false, exitObservedAt: null } @@ -234,7 +264,7 @@ export async function acquireAcpStructuredSession(input: { if (unbind) { session.unbindReadingControl = unbind } - if (child.exited) { + if (child.exited || connection.closed) { throw new Error(child.stderrTail() || `${spec.command} exited while starting`) } return { acquisition: { process, link, acquisitionGeneration: generation }, session } @@ -248,5 +278,7 @@ export async function acquireAcpStructuredSession(input: { ) } throw error + } finally { + clearTimeout(startupDeadline) } } diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index d8b5dad39eb1..86da6cf772a1 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -115,6 +115,7 @@ export async function openAcpAdapterRig( launch?: Partial initialize?: Record script?: (agent: AcpScriptedAgent) => void + deps?: Partial } = {} ): Promise { const rig = await openProviderTimelineRig() @@ -160,7 +161,8 @@ export async function openAcpAdapterRig( onDispatchSettledLate: (settlement) => settled.push(settlement), mintGeneration: () => 'gen-acp', now: () => 5_000, - cancelTimeoutMs: 1_000 + cancelTimeoutMs: 1_000, + ...options.deps }) const frames = () => current?.agent.frames ?? [] return { @@ -210,6 +212,22 @@ export async function openAcpAdapterRig( } } +const HELLO = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } as const + +/** A person's send of `hello` under `clientMessageId`. */ +export function sendHello(rig: AcpAdapterRig, clientMessageId: string, fence = 1) { + return rig.adapter.dispatch({ sessionId: SESSION, clientMessageId, body: HELLO, fence }) +} + +/** One streamed reply chunk of the turn Grok runs under `promptId`. */ +export function replyChunk(promptId: string, text: string, meta: Record = {}) { + return { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } }, + _meta: { promptId, ...meta } + } +} + export function waitFor(assertion: () => T | Promise): Promise { return vi.waitFor(assertion, { timeout: 2_000, interval: 5 }) } diff --git a/src/main/acp/acp-structured-lane.ts b/src/main/acp/acp-structured-lane.ts index 4b0a43079fdc..2ded3d1beec1 100644 --- a/src/main/acp/acp-structured-lane.ts +++ b/src/main/acp/acp-structured-lane.ts @@ -48,6 +48,7 @@ export class AcpStructuredLane { readonly translator: AcpTimelineTranslator private readonly assembler: ProviderTimelineAssembler private readonly backlog: ProviderTimelineEvent[] = [] + private readonly turnWatchers = new Set<() => void>() private retryTimer: ReturnType | null = null private failed = false private disposed = false @@ -79,6 +80,20 @@ export class AcpStructuredLane { this.drain() } + /** Resolves once `turnId` is no longer the open turn, or nothing more can be written. */ + whenTurnLeaves(turnId: string): Promise { + return new Promise((resolve) => { + const check = (): void => { + if (this.failed || this.disposed || this.openTurnId !== turnId) { + this.turnWatchers.delete(check) + resolve() + } + } + this.turnWatchers.add(check) + check() + }) + } + /** The sink drained: whatever it held back goes now. */ retry(): void { this.drain() @@ -112,6 +127,7 @@ export class AcpStructuredLane { this.clearRetry() this.backlog.length = 0 this.assembler.dispose() + this.notifyTurnWatchers() } private drain(): void { @@ -126,6 +142,7 @@ export class AcpStructuredLane { } this.failed = true this.backlog.length = 0 + this.notifyTurnWatchers() this.deps.onFailed(admission.reason) return } @@ -134,6 +151,13 @@ export class AcpStructuredLane { this.deps.onInputAccepted(event.clientMessageId) } } + this.notifyTurnWatchers() + } + + private notifyTurnWatchers(): void { + for (const check of this.turnWatchers) { + check() + } } private clearRetry(): void { diff --git a/src/main/acp/acp-structured-session-adapter-deps.ts b/src/main/acp/acp-structured-session-adapter-deps.ts index c293782aa74a..ccf482834ce1 100644 --- a/src/main/acp/acp-structured-session-adapter-deps.ts +++ b/src/main/acp/acp-structured-session-adapter-deps.ts @@ -9,6 +9,9 @@ import type { AcpLaunchSpec } from './acp-launch-specs' import type { SpawnAcpStructuredChild } from './acp-structured-child' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +/** How long a Stop waits for the agent to end its turn before the child is closed. */ +export const ACP_CANCEL_TIMEOUT_MS = 10_000 + export type AcpStructuredSessionAdapterDeps = { spec: AcpLaunchSpec resolveLaunch: (input: { identity: AgentSessionJournalIdentity }) => Promise @@ -30,5 +33,7 @@ export type AcpStructuredSessionAdapterDeps = { mintLinkId?: () => string /** Bounds a Stop's wait for the agent to end its turn; past it the child is closed. */ cancelTimeoutMs?: number + /** Bounds the handshake and session load or creation; past it the start fails. */ + startupTimeoutMs?: number isWindowsProcessStartTimeAvailable?: () => boolean } diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts new file mode 100644 index 000000000000..e2c4f6ba4c98 --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -0,0 +1,294 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + providerTurnId, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { StructuredAgentSessionTaskQueue } from '../native-chat/agent-session-wire/structured-agent-session-task-queue' +import { AcpStartupTimeoutError } from './acp-structured-acquire' +import { + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk, + sendHello, + waitFor, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +async function journalTurns(rig: AcpAdapterRig) { + return (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) +} + +const turnIdOf = (promptId: string) => providerTurnId(promptId, PROVIDER_SESSION) + +describe('ACP Stop names a turn', () => { + it('a Stop naming a turn that has ended stops neither the turn running now nor its follow-up', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'old') + const old = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:old', 'done')) + rig.child().agent.reply(old, { stopReason: 'end_turn' }) + await sendHello(rig, 'new') + const current = await rig.frame('session/prompt', 1) + rig.child().agent.notify('session/update', replyChunk('prompt:new', 'working')) + await sendHello(rig, 'follow-up') + await rig.settle() + rig + .child() + .agent.on('session/cancel', () => + rig.child().agent.reply(current, { stopReason: 'cancelled' }) + ) + const live = turnIdOf('prompt:new') + await expect( + rig.adapter.cancelTurn({ + sessionId: SESSION, + fence: 1, + turnId: turnIdOf('prompt:old'), + resolveLiveTurnId: () => live + }) + ).resolves.toEqual({ cancelled: false, refusal: { turnNotRunning: true } }) + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(0) + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['old', 'new']) + expect(rig.adapter.holdsDispatch(SESSION)).toBe(true) + }) + + it('a Stop naming an ended turn during the gap before the next prompt opens stops nothing', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'old') + const old = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:old', 'done')) + rig.child().agent.reply(old, { stopReason: 'end_turn' }) + await sendHello(rig, 'new') + await rig.frame('session/prompt', 1) + await rig.settle() + // The journal shows no turn running: Grok has not echoed the new prompt yet. + await expect( + rig.adapter.cancelTurn({ + sessionId: SESSION, + fence: 1, + turnId: turnIdOf('prompt:old'), + resolveLiveTurnId: () => null + }) + ).resolves.toEqual({ cancelled: false, refusal: { turnNotRunning: true } }) + expect(rig.sent('session/cancel')).toHaveLength(0) + }) + + it('a Stop naming the turn running now stops it and withdraws what is held behind it', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'new') + const current = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:new', 'working')) + await sendHello(rig, 'follow-up') + await rig.settle() + rig + .child() + .agent.on('session/cancel', () => + rig.child().agent.reply(current, { stopReason: 'cancelled' }) + ) + const live = turnIdOf('prompt:new') + await expect( + rig.adapter.cancelTurn({ + sessionId: SESSION, + fence: 1, + turnId: live, + resolveLiveTurnId: () => live + }) + ).resolves.toEqual({ cancelled: true }) + expect(rig.sent('session/cancel')).toHaveLength(1) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'follow-up', state: 'rejected' }) + ]) + ) + }) +}) + +describe('ACP Stop of a turn Grok began itself', () => { + it('cancels a turn opened by a background completion and waits for it to end', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + agent.notify('session/update', replyChunk('task-completed-background-1', 'Working on it')) + await rig.settle() + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'running' }) + agent.on('session/cancel', () => + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'turn_completed', + prompt_id: 'task-completed-background-1', + stop_reason: 'cancelled' + } + }) + ) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + expect(rig.sent('session/cancel')).toHaveLength(1) + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'interrupted' }) + expect(rig.child().closes).toBe(0) + }) + + it('closes the child when Grok never ends its own turn after the cancel', async () => { + const rig = await openAcpAdapterRig({ deps: { cancelTimeoutMs: 20 } }) + await rig.acquire() + rig.child().agent.notify('session/update', replyChunk('task-completed-background-1', 'Busy')) + await rig.settle() + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: false + }) + await waitFor(() => expect(rig.child().closes).toBe(1)) + await waitFor(async () => + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'unverifiable' }) + ) + }) +}) + +describe('ACP connection loss', () => { + it('settles a send the agent never took as unknown, stops the child and tells the host', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'lost') + await rig.frame('session/prompt') + await sendHello(rig, 'held') + rig.child().stdout.end() + await rig.settle() + await waitFor(() => + expect(rig.lifecycle).toMatchObject([ + { type: 'ended', cause: 'unexpected-exit', acquisitionGeneration: 'gen-acp' } + ]) + ) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'lost', state: 'unknown' }), + expect.objectContaining({ clientMessageId: 'held', state: 'rejected' }) + ]) + ) + expect(rig.child().closes).toBe(1) + // Nothing was opened for a send the agent never echoed, so nothing claims it completed. + expect(await journalTurns(rig)).toEqual([]) + await expect(sendHello(rig, 'after')).rejects.toThrow(/no live grok child/) + }) + + it('leaves a running turn unverifiable, never completed, when the stream breaks mid-turn', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'lost') + await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:lost', 'partial')) + await rig.settle() + rig.child().stdout.end() + await waitFor(async () => + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'unverifiable' }) + ) + expect(rig.child().closes).toBe(1) + }) + + it('treats an answer Orca cannot read as a broken connection, not a finished turn', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'garbled') + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:garbled', 'partial')) + rig.child().agent.reply(prompt, { stopReason: 42 }) + await waitFor(async () => + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'unverifiable' }) + ) + expect(rig.child().closes).toBe(1) + }) +}) + +describe('ACP refusals', () => { + it('rejects a send the agent refused before starting its turn', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/prompt', (frame) => agent.fail(frame, -32602, 'unsupported input')) + }) + await rig.acquire() + await sendHello(rig, 'refused') + await waitFor(() => + expect(rig.settled).toMatchObject([ + { clientMessageId: 'refused', state: 'rejected', rejection: { kind: 'providerRejected' } } + ]) + ) + expect(await journalTurns(rig)).toEqual([]) + // The session takes the next send. + rig.child().agent.on('session/prompt', () => {}) + await sendHello(rig, 'next') + await rig.frame('session/prompt', 1) + }) + + it('ends a started turn as failed when the agent answers its prompt with an error', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'failing') + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:failing', 'partial')) + await rig.settle() + rig.child().agent.fail(prompt, -32603, 'model overloaded') + await waitFor(async () => + expect((await journalTurns(rig)).at(-1)).toMatchObject({ + state: 'completed', + outcome: 'failure' + }) + ) + expect(rig.settled).toMatchObject([{ clientMessageId: 'failing', providerIdentity: {} }]) + expect(rig.child().closes).toBe(0) + }) +}) + +describe('ACP startup that never answers', () => { + it('lets a close stop the child while the handshake is unanswered', async () => { + const rig = await openAcpAdapterRig({ script: (agent) => agent.on('initialize', () => {}) }) + const queue = new StructuredAgentSessionTaskQueue() + const acquiring = queue.serialize(SESSION, () => rig.acquire()) + const failed = acquiring.catch((error: unknown) => error) + await rig.frame('initialize') + // What the host's close does outside the queue, then its queued stop. + await rig.adapter.abandonStart(SESSION) + const closing = queue.serialize(SESSION, () => rig.adapter.closeSession(SESSION)) + expect(rig.child().closes).toBe(1) + expect(await failed).toMatchObject({ message: 'Grok was closed while starting' }) + await expect(closing).resolves.toBe(true) + expect(rig.lifecycle).toEqual([]) + }) + + it('fails the start once the handshake outlasts its bound, and stops the child', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => agent.on('session/new', () => {}), + deps: { startupTimeoutMs: 20 } + }) + const failure = await rig.acquire().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AcpStartupTimeoutError) + expect(rig.child().closes).toBe(1) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + }) +}) + +describe('ACP session release', () => { + it('keeps nothing of a chat once its child is proven closed', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await rig.adapter.closeSession(SESSION) + expect(Reflect.get(rig.adapter, 'sessions').size).toBe(0) + expect(rig.adapter.readCommands(SESSION)).toBeUndefined() + }) + + it('keeps nothing of a chat whose child exited on its own', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + rig.child().exit() + await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) + expect(Reflect.get(rig.adapter, 'sessions').size).toBe(0) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter-resume.test.ts b/src/main/acp/acp-structured-session-adapter-resume.test.ts new file mode 100644 index 000000000000..60678d2dc72d --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + messageText, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { + GROK_CONFIG_OPTIONS, + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk, + sendHello +} from './acp-structured-adapter.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const hello: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }] +} + +const userChunk = (text: string) => ({ + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'user_message_chunk', content: { type: 'text', text } }, + _meta: { isReplay: true } +}) + +/** Grok answers every load after the first by replaying the turn `promptId` it saved. */ +function replaysOnReload(promptId: string, reply: string) { + let loads = 0 + return (agent: AcpScriptedAgent) => + agent.on('session/load', (frame) => { + if (loads++ > 0) { + agent.notify('session/update', userChunk('hello')) + agent.notify('session/update', replyChunk(promptId, reply, { isReplay: true })) + } + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) +} + +const resume = { launch: { resume: { sessionId: PROVIDER_SESSION, replaceableKey: null } } } + +describe('ACP resume reconciles what the agent replays against the journal', () => { + it('writes nothing again for a turn the journal already settled', async () => { + const rig = await openAcpAdapterRig({ ...resume, script: replaysOnReload('prompt:m1', 'hi') }) + await rig.acquire() + await rig.rig.eventSink.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, { + turnScope: { kind: 'thread' } + }) + await sendHello(rig, 'm1') + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:m1', 'hi')) + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + await rig.settle() + const before = await rig.rig.rows() + expect(before.some((row) => messageText(row.body) === 'hi')).toBe(true) + await rig.adapter.closeSession(SESSION) + await rig.acquire({ fence: 2 }) + await rig.settle() + expect(await rig.rig.rows()).toEqual(before) + }) + + it('adopts a reply Grok saved that Orca never wrote, joined to the send already in the journal', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + script: (agent) => + agent.on('session/load', (frame) => { + agent.notify('session/update', userChunk('hello')) + agent.notify( + 'session/update', + replyChunk('prompt:old-message', 'Saved reply after host crash', { isReplay: true }) + ) + agent.reply(frame, { configOptions: [] }) + }) + }) + // Orca wrote the send, then crashed before any of Grok's reply reached the journal. + await rig.rig.eventSink.appendItem( + { provider: 'orca', clientMessageId: 'old-message' }, + hello, + { + turnScope: { kind: 'thread' } + } + ) + await rig.settle() + await rig.acquire({ fence: 2 }) + await rig.settle() + const rows = await rig.rig.rows() + const messages = rows.filter((row) => row.body.kind === 'message') + expect(messages.map((row) => messageText(row.body))).toEqual([ + 'hello', + 'Saved reply after host crash' + ]) + const turn = rows.flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turn).toMatchObject([ + { state: 'completed', userItemId: agentJournalSubmissionKey('old-message') } + ]) + }) + + it('keeps the slash commands Grok reports while it loads', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + script: (agent) => + agent.on('session/load', (frame) => { + agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [{ name: 'compact', description: 'Compress', input: null }] + } + }) + agent.reply(frame, { configOptions: [] }) + }) + }) + await rig.acquire() + await rig.settle() + expect(rig.adapter.readCommands(SESSION)).toEqual([ + { name: 'compact', kind: 'command', description: 'Compress' } + ]) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index c469364a8767..a9faf1acb1bf 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -75,48 +75,6 @@ describe('ACP structured session adapter: acquire', () => { }) }) - it('loads the session this chat proved, replaying nothing over a journal that holds it', async () => { - // Unmarked, as an agent that does not flag its replay sends it. - const replayed = (update: Record) => ({ - sessionId: PROVIDER_SESSION, - update, - _meta: { promptId: 'old-prompt' } - }) - const rig = await openAcpAdapterRig({ - launch: { resume: { sessionId: PROVIDER_SESSION, replaceableKey: null } }, - script: (agent) => - agent.on('session/load', (frame) => { - agent.notify( - 'session/update', - replayed({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'hi' } }) - ) - agent.notify( - 'session/update', - replayed({ - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: 'yo' } - }) - ) - agent.notify( - 'session/update', - replayed({ - sessionUpdate: 'tool_call', - toolCallId: 'call-1', - title: 'ls', - status: 'completed' - }) - ) - agent.reply(frame, { configOptions: [] }) - }) - }) - const acquired = await rig.acquire({ fence: 2 }) - expect(rig.sent('session/load')[0]?.params).toMatchObject({ sessionId: PROVIDER_SESSION }) - expect(rig.sent('session/new')).toHaveLength(0) - expect(acquired.link).toMatchObject({ origin: 'resumed', mintedAtFence: 2 }) - await rig.settle() - expect(await rig.rig.rows()).toEqual([]) - }) - it('replaces a created session the agent never saved with a new one', async () => { const rig = await openAcpAdapterRig({ launch: { resume: { sessionId: 'lost', replaceableKey: 'old-key' } }, diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index e4ac7b241655..f7dfb41b8e04 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -12,6 +12,7 @@ import { AgentSessionAcquisitionExitUnprovenError, isAgentSessionPreSpawnError, type AgentSessionAcquisition, + type AgentSessionCancelOutcome, type AgentSessionDispatchOutcome, type StructuredAgentSessionAcquireInput, type StructuredAgentSessionAdapter, @@ -20,16 +21,25 @@ import { import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' import { withObservedProviderExit } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { acpAgentName, acquireAcpStructuredSession } from './acp-structured-acquire' -import { AcpRequestTimeoutError } from './acp-errors' import type { AcpStructuredChild } from './acp-structured-child' -import { endAcpStructuredSession, type AcpStructuredSession } from './acp-structured-session' -import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' +import { + closeAcpSessionJournal, + endAcpStructuredSession, + type AcpStructuredSession +} from './acp-structured-session' +import { + ACP_CANCEL_TIMEOUT_MS, + type AcpStructuredSessionAdapterDeps +} from './acp-structured-session-adapter-deps' import type { ContentBlock } from './generated/acp-protocol.generated' +type StartingChild = { child: AcpStructuredChild; abandoned: boolean } + export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter { + /** Live children, and ones whose exit is not yet proven; a proven exit removes its entry. */ private readonly sessions = new Map() - /** Children still starting, so a close during the acquire can stop them. */ - private readonly starting = new Map() + /** Children still starting, so a close need not wait behind their acquire to stop them. */ + private readonly starting = new Map() constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {} @@ -47,33 +57,44 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte ) } const generation = this.deps.mintGeneration?.() ?? randomUUID() - const tracked: { child: AcpStructuredChild | null } = { child: null } + const tracked: { starting: StartingChild | null } = { starting: null } try { const { acquisition, session } = await acquireAcpStructuredSession({ acquire: input, deps: this.deps, generation, - track: (started) => { - tracked.child = started - this.starting.set(sessionId, started) + track: (child) => { + tracked.starting = { child, abandoned: false } + this.starting.set(sessionId, tracked.starting) }, onExit: (session) => { if (session && this.sessions.get(sessionId) === session) { - endAcpStructuredSession(session, this.now(), this.deps.onEvent) + this.finish(session, this.now()) } }, + onConnectionLost: (session, error) => this.connectionLost(session, error), onSettled: (settlement) => this.deps.onDispatchSettledLate?.({ sessionId, ...settlement }), forceClose: (id) => void this.forceCloseSession(id) }) + if (tracked.starting?.abandoned) { + session.lane.dispose() + throw new Error('closed while starting') + } this.sessions.set(sessionId, session) return acquisition } catch (error) { - const child = tracked.child + const child = tracked.starting?.child + const abandoned = tracked.starting?.abandoned === true // Checked before the close below, which would make any exit look like one Orca asked for. - const exitedOnItsOwn = child?.exited === true + const exitedOnItsOwn = child?.exited === true && !abandoned if (child && !(await child.close().catch(() => false))) { throw new AgentSessionAcquisitionExitUnprovenError(error) } + if (abandoned) { + throw new Error(`${acpAgentName(this.deps.spec.agent)} was closed while starting`, { + cause: error + }) + } if (child && exitedOnItsOwn && !isAgentSessionPreSpawnError(error)) { // The agent's own last words are what a person can act on. throw new AgentSessionAcquisitionExitProvenError( @@ -82,12 +103,22 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } throw error } finally { - if (this.starting.get(sessionId) === tracked.child) { + if (tracked.starting && this.starting.get(sessionId) === tracked.starting) { this.starting.delete(sessionId) } } } + /** A close that must not wait behind an acquire still starting: its child is stopped now, and + * that acquire fails through the start-failure surface. */ + abandonStart = async (sessionId: string): Promise => { + const starting = this.starting.get(sessionId) + if (starting) { + starting.abandoned = true + await starting.child.close() + } + } + async dispatch(input: { sessionId: string clientMessageId: string @@ -119,24 +150,57 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = async (input) => { const session = this.live(input.sessionId) + // `session/cancel` stops whatever the session runs, so a Stop naming a turn that has since + // ended must stop nothing newer: not the turn running now, nor the follow-ups behind it. + const liveTurnId = input.resolveLiveTurnId?.() ?? session.lane.openTurnId + if (input.turnId !== undefined && input.turnId !== liveTurnId) { + return { cancelled: false, refusal: { turnNotRunning: true } } + } const withdrew = session.turns.withdrawQueued() session.prompts.cancelAll() - if (!session.turns.running) { - return withdrew - ? { cancelled: true } - : { cancelled: false, refusal: { turnNotRunning: true } } + if (session.turns.running) { + try { + // Ends when the agent answers the prompt `cancelled`; open permissions are declined first. + // Past the bound the connection closes, which stops the child. + await session.runtime.cancel() + return { cancelled: true } + } catch { + return { cancelled: false } + } + } + const agentTurnId = session.lane.openTurnId + if (agentTurnId !== null) { + return this.cancelAgentTurn(session, agentTurnId) } + return withdrew ? { cancelled: true } : { cancelled: false, refusal: { turnNotRunning: true } } + } + + /** A turn the agent began itself, as it does when a background task finishes: no prompt of Orca's + * answers, so the turn's own end does, within the same bound a prompt's cancel has. */ + private async cancelAgentTurn( + session: AcpStructuredSession, + turnId: string + ): Promise { + let timer: ReturnType | undefined + const deadline = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), this.deps.cancelTimeoutMs ?? ACP_CANCEL_TIMEOUT_MS) + }) try { - // Ends when the agent answers the prompt `cancelled`; open permissions are declined first. - await session.runtime.cancel() - return { cancelled: true } - } catch (error) { - if (error instanceof AcpRequestTimeoutError) { - // The agent never ended the turn; its connection is closed, so the child goes too. - void this.forceCloseSession(input.sessionId) + const left = session.lane.whenTurnLeaves(turnId).then(() => true) + await session.runtime.cancel({ agentTurn: true }) + if ((await Promise.race([left, deadline])) && session.journalClosed === null) { + return { cancelled: true } } - return { cancelled: false } + } catch { + // The notification could not be written: the connection's own close ends the session. + } finally { + clearTimeout(timer) } + if (session.journalClosed === null) { + // The agent never ended its turn, so the child goes and the turn ends with it. + void this.forceCloseSession(session.sessionId) + } + return { cancelled: false } } answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => @@ -195,7 +259,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte /** True only once the child is proven gone, or when this adapter runs none for the session. */ private async stop(sessionId: string, requested: boolean): Promise { const starting = this.starting.get(sessionId) - if (starting && !(await starting.close())) { + if (starting && !(await starting.child.close())) { return false } const session = this.sessions.get(sessionId) @@ -203,14 +267,41 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return true } session.closeRequested ||= requested - session.lane.flush() + if (session.journalClosed === null) { + session.lane.flush() + } const proven = await session.child.close() if (proven) { - endAcpStructuredSession(session, session.exitObservedAt ?? this.now(), this.deps.onEvent) + this.finish(session, session.exitObservedAt ?? this.now()) } return proven } + /** The child's exit is proven: the host hears it, and nothing of the child stays here. */ + private finish(session: AcpStructuredSession, observedAt: number): void { + endAcpStructuredSession(session, observedAt, this.deps.onEvent) + if (this.sessions.get(session.sessionId) === session) { + this.sessions.delete(session.sessionId) + } + } + + /** The connection broke while the child may still run: nothing more it says can be journaled, so + * the journal closes now and the child is stopped; the host hears `ended` once that is proven. */ + private connectionLost(session: AcpStructuredSession | null, error: Error): void { + if ( + !session || + this.sessions.get(session.sessionId) !== session || + session.journalClosed !== null + ) { + return + } + closeAcpSessionJournal( + session, + `${session.spec.agent} ACP connection closed: ${error.message || error.name}` + ) + void this.stop(session.sessionId, false) + } + private promptBlocks(body: AgentJournalMessageItem): ContentBlock[] | null { const blocks: ContentBlock[] = [] for (const block of body.blocks) { @@ -225,7 +316,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte private live(sessionId: string): AcpStructuredSession { const session = this.sessions.get(sessionId) - if (!session || session.ended) { + if (!session || session.journalClosed !== null) { throw new Error(`no live ${this.deps.spec.agent} child owns ${sessionId}`) } return session diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts index d60c091bfa17..27b177360ee3 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -27,6 +27,9 @@ export type AcpStructuredSession = { restoreSkipped: readonly string[] /** Orca asked this child to stop; its exit is then a requested close. */ closeRequested: boolean + /** Why nothing this child says reaches the journal any more; null while it does. */ + journalClosed: string | null + /** Its exit is proven and the host was told. */ ended: boolean exitObservedAt: number | null unbindReadingControl?: () => void @@ -51,10 +54,27 @@ export function routeAcpSessionEvent( } /** - * The child's exit, once. Open requests die with it, held sends never left Orca, the running turn - * is one the host never heard end (`unverifiable` until death evidence revises it), and the host - * hears `ended` so it releases the session. + * Nothing more this child says reaches the journal, once: on its exit, or when its connection broke + * while it may still run. Open requests die, held sends never left Orca, the running send's fate is + * unknown, and the running turn is one Orca never heard end (`unverifiable` until death evidence + * revises it). */ +export function closeAcpSessionJournal(session: AcpStructuredSession, reason: string): void { + if (session.journalClosed !== null) { + return + } + session.journalClosed = reason + session.prompts.clear() + session.turns.end(reason) + session.lane.apply([{ type: 'session.ended', verdict: UNVERIFIABLE_TURN_VERDICT }]) + session.lane.flush() + session.lane.dispose() + session.unbindReadingControl?.() + session.runtime.close(new Error(reason)) +} + +/** The child's proven exit, once: the journal closes if it has not, and the host hears `ended` so + * it releases the session. */ export function endAcpStructuredSession( session: AcpStructuredSession, observedAt: number, @@ -68,14 +88,9 @@ export function endAcpStructuredSession( const stderr = session.child.stderrTail() const reason = session.closeRequested ? `${session.spec.agent} ACP agent closed by Orca` - : `${session.spec.agent} ACP agent exited${stderr ? `: ${stderr}` : ''}` - session.prompts.clear() - session.turns.end(reason) - session.lane.apply([{ type: 'session.ended', verdict: UNVERIFIABLE_TURN_VERDICT }]) - session.lane.flush() - session.lane.dispose() - session.unbindReadingControl?.() - session.runtime.close(new Error(reason)) + : (session.journalClosed ?? + `${session.spec.agent} ACP agent exited${stderr ? `: ${stderr}` : ''}`) + closeAcpSessionJournal(session, reason) const detail = stderr ? providerDiagnostic(stderr, 'person') : undefined onEvent?.({ type: 'ended', diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index 19427b93153a..4d5525432277 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -1,7 +1,8 @@ // The sends of one ACP session. ACP runs one prompt at a time, so a message sent while a turn runs // is held here and sent as the next prompt once the running one is answered. Each send is settled // exactly once: accepted when the agent's first event for its turn (or its answer) arrives, -// rejected when the agent refused it or it never left Orca, unknown when the agent died with it. +// rejected when the agent refused it or it never left Orca, unknown when the agent died with it or +// its connection broke before it answered. import { agentSessionFailureFact, @@ -13,7 +14,7 @@ import { type AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' -import { AcpRpcError } from './acp-errors' +import { AcpAgentError } from './acp-errors' import type { AcpSessionRuntime } from './acp-session-runtime' import type { AcpStructuredLane } from './acp-structured-lane' import type { ContentBlock } from './generated/acp-protocol.generated' @@ -32,6 +33,9 @@ export type AcpStructuredTurnsDeps = { agentName: string now: () => number settle: (settlement: AcpDispatchSettlement) => void + /** The prompt failed without an answer from the agent: the connection is no longer trustworthy, + * and the session's end settles the send. */ + onTransportFault: (error: unknown) => void } export class AcpStructuredTurns { @@ -115,14 +119,21 @@ export class AcpStructuredTurns { if (this.active !== send) { return } - lane.apply(lane.translator.promptFailed(send.clientMessageId, error, this.deps.now())) - if (error instanceof AcpRpcError && this.unsettled.has(send.clientMessageId)) { + if (!(error instanceof AcpAgentError)) { + // No answer from the agent, so neither a refusal nor an end: the send stays running here + // until the session's end settles it `unknown`. + this.deps.onTransportFault(error) + return + } + if (lane.translator.promptRefused(send.clientMessageId)) { // The agent answered the prompt with an error before starting it: its own refusal. const detail = providerDiagnostic(error.message, 'person') this.reject( send.clientMessageId, agentSessionFailureFact('providerRejected', detail ? { detail } : {}) ) + } else { + lane.apply(lane.translator.promptFailed(send.clientMessageId, error, this.deps.now())) } this.finish(send) } diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 1a93fe55dcf9..ca759ee0fceb 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -12,9 +12,9 @@ import { type AcpDialect, type AcpRequestPresentation } from './acp-dialects/acp-dialect' -import { acpJournalTurnIsSettled, acpJournalTurnHasUser } from './acp-journal-turns' +import { acpJournalTurnIsSettled } from './acp-journal-turns' import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' -import { AcpReplayUserMessages } from './acp-replay-user-messages' +import { acpReplayedUser, AcpReplayUserMessages } from './acp-replay-user-messages' import type { AcpSessionEvent } from './acp-session-runtime' import { translateAcpRequest } from './acp-timeline-requests' export { acpTurnEnd } from './acp-prompt-turns' @@ -85,6 +85,17 @@ export class AcpTimelineTranslator { return this.finishPrompt(clientMessageId, 'error', at) } + /** The agent refused the prompt before its turn began: forgets it and answers true. False once + * the turn opened, when the refusal ends that turn instead (`promptFailed`). */ + promptRefused(clientMessageId: string): boolean { + const prompt = this.prompts.current + if (prompt?.clientMessageId !== clientMessageId || prompt.opened) { + return false + } + this.prompts.current = undefined + return true + } + private finishPrompt( clientMessageId: string, stopReason: string, @@ -211,15 +222,13 @@ export class AcpTimelineTranslator { if (isReplay && this.replay) { this.replay.turn = turn if (this.replay.pendingUser) { - const existing = acpJournalTurnHasUser(this.options.journalItems(), turn) - if (!existing) { - events.push({ - type: 'item.update', - item: `replay-user:${turn}:`, - body: this.replay.pendingUser, - join: { thread: this.options.sessionId, turn } - }) - } + events.push( + ...acpReplayedUser( + this.options.journalItems(), + { thread: this.options.sessionId, turn }, + this.replay.pendingUser + ) + ) this.replay.pendingUser = undefined } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index ba83184ec405..780543356525 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -264,6 +264,11 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return this.closePromise } + /** No route exists until an acquire returns, so every adapter is asked. */ + abandonStart = async (sessionId: string): Promise => { + await Promise.all(this.adapters().map((adapter) => adapter.abandonStart?.(sessionId))) + } + /** Drops a per-session stop receipt after the host releases its durable owner. */ acknowledgeSessionRelease = (sessionId: string): void => { this.routes.delete(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index ad29ef58e651..0fd8f8ecf348 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -391,4 +391,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & disposeSession?(sessionId: string): Promise /** Host acknowledgement that the proven-dead child, lease and journal owner are released. */ acknowledgeSessionRelease?(sessionId: string): void + /** A close that must not wait behind an acquire still in its handshake: stops that child now, so + * the acquire fails and the close queued behind it runs. Called outside the session's serialize; + * a no-op when nothing is starting. Absent where an acquire never waits on the provider. */ + abandonStart?(sessionId: string): Promise } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-abandons-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-abandons-start.test.ts new file mode 100644 index 000000000000..d67fd0b40e20 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-abandons-start.test.ts @@ -0,0 +1,48 @@ +// A close must not wait behind a start the provider never answers: the start runs under the +// session's queue, and the close's own stop is queued behind it. + +import { expect, it, vi } from 'vitest' +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + adapter, + attachParams, + CALLER, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' + +it('a close stops a start the provider never answers instead of queueing behind it', async () => { + const state = hostTestState() + let failStart: ((error: Error) => void) | undefined + state.acquire.mockImplementation( + () => + new Promise((_resolve, reject) => { + failStart = reject + }) + ) + // The provider's child stops, so its unanswered handshake fails the start. + const abandonStart = vi.fn(async () => failStart?.(new Error('closed while starting'))) + const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + store: state.store, + adapter: { ...adapter(), abandonStart }, + journalDatabase: openTestJournalHostDatabase(state.root), + recoveryCapsule: new AgentSessionRecoveryCapsule(state.root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => HOST_TEST_NOW + }) + replaceHostTestState({ store: state.store, host }) + const attaching = host.attach(CALLER, attachParams()) + await vi.waitFor(() => expect(state.acquire).toHaveBeenCalled()) + await host.close(SESSION, 'user-close') + expect(abandonStart).toHaveBeenCalledWith(SESSION) + expect((await attaching).ok).toBe(false) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 3a35b4b138ae..0be99ec1c893 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -160,8 +160,18 @@ export function createStructuredAgentSessionConversationLifetime(host: { }, /** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is * still queued will not be sent. */ - close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => - serialize(sessionId, async () => { + close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => { + // Outside the queue: a start the provider never answers must not hold the close behind it. + void deps() + .adapter.abandonStart?.(sessionId) + ?.catch((error: unknown) => + deps().logger.warn('stopping a starting provider for a close failed', { + scope: 'abandon-start', + sessionId, + error + }) + ) + return serialize(sessionId, async () => { readRefusals.forget(sessionId) const session = sessions.get(sessionId) if (session) { @@ -171,5 +181,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) await closeConversation(sessionId) }) + } } } From b7067a5e855d84ec32f5de0b290b9f51906272be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:09:16 -0700 Subject: [PATCH 098/269] test(native-chat): read the released ACP chat through the adapter's own answers, not its private map --- .../acp/acp-structured-session-adapter-lifecycle.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index e2c4f6ba4c98..889426e30d54 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -278,8 +278,10 @@ describe('ACP session release', () => { it('keeps nothing of a chat once its child is proven closed', async () => { const rig = await openAcpAdapterRig() await rig.acquire() + expect(rig.adapter.backgroundTaskStops(SESSION)).toBeDefined() await rig.adapter.closeSession(SESSION) - expect(Reflect.get(rig.adapter, 'sessions').size).toBe(0) + // Both answer from the entry the adapter holds for a chat; none is left. + expect(rig.adapter.backgroundTaskStops(SESSION)).toBeUndefined() expect(rig.adapter.readCommands(SESSION)).toBeUndefined() }) @@ -288,7 +290,7 @@ describe('ACP session release', () => { await rig.acquire() rig.child().exit() await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) - expect(Reflect.get(rig.adapter, 'sessions').size).toBe(0) + expect(rig.adapter.backgroundTaskStops(SESSION)).toBeUndefined() await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) }) }) From 0bac7320eb62d7f9bbd04049bc3779ee6b1f181b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:15:39 -0700 Subject: [PATCH 099/269] fix(native-chat): the router's abandonStart awaits only promises --- .../structured-agent-session-adapter-router.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 780543356525..86be07b1116a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -266,7 +266,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi /** No route exists until an acquire returns, so every adapter is asked. */ abandonStart = async (sessionId: string): Promise => { - await Promise.all(this.adapters().map((adapter) => adapter.abandonStart?.(sessionId))) + await Promise.all( + this.adapters().map((adapter) => adapter.abandonStart?.(sessionId) ?? Promise.resolve()) + ) } /** Drops a per-session stop receipt after the host releases its durable owner. */ From 06a495ca04513016dce72014bba52405f28f5a0b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:22:02 -0700 Subject: [PATCH 100/269] test(native-chat): type the ACP rig's sent message as a journal message --- src/main/acp/acp-structured-adapter.test-support.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index 86da6cf772a1..1d157f6896ad 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -2,6 +2,7 @@ // protocol runtime, translator and assembler, and a real on-disk journal to read back. import { vi } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { @@ -212,7 +213,11 @@ export async function openAcpAdapterRig( } } -const HELLO = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } as const +const HELLO: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }] +} /** A person's send of `hello` under `clientMessageId`. */ export function sendHello(rig: AcpAdapterRig, clientMessageId: string, fence = 1) { From 165b68a89a609db3aa695730e115d4b0f286a446 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:10:51 -0700 Subject: [PATCH 101/269] fix(native-chat): a load's replay is reconciled against the journal at the write The sink a start writes through cannot read the journal until the start succeeds, so the translator's own view was always empty during session/load: a resumed Grok chat wrote the user's message a second time. A replayed opener is now an input.replayed event the assembler decides at the write, and a replayed item for a turn the provider completed writes nothing, while a turn that ended without the provider's end still takes the saved tail. --- src/main/acp/acp-journal-turns.ts | 33 ---------- src/main/acp/acp-replay-user-messages.ts | 33 ++++------ src/main/acp/acp-session-update.ts | 22 ++++--- src/main/acp/acp-timeline-translator.ts | 64 +++++++++++-------- .../provider-timeline-assembler.ts | 1 + .../provider-timeline-decision.ts | 16 +++++ .../provider-timeline-decisions.ts | 3 + .../provider-timeline-event.ts | 30 ++++++++- .../provider-timeline-item-decisions.ts | 16 +++++ .../provider-timeline-transition-layout.ts | 10 +++ .../provider-timeline-turn-decisions.ts | 47 ++++++++++++++ 11 files changed, 185 insertions(+), 90 deletions(-) diff --git a/src/main/acp/acp-journal-turns.ts b/src/main/acp/acp-journal-turns.ts index 6d1b95c71274..2f91fd6cbdb3 100644 --- a/src/main/acp/acp-journal-turns.ts +++ b/src/main/acp/acp-journal-turns.ts @@ -1,6 +1,5 @@ import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' -import { providerTimelineKeyPart } from '../native-chat/agent-session-timeline/provider-timeline-identity' /** Read provider ids back from the assembler's existing turn records. */ export function acpJournalTurnKey(row: AgentJournalRenderItem): string | undefined { @@ -28,35 +27,3 @@ export function acpJournalToolTurn( const turn = rows.find((row) => row.itemId === turnItemId) return turn && acpJournalTurnKey(turn) } - -export function acpJournalTurnIsSettled( - rows: readonly AgentJournalRenderItem[], - key: string -): boolean { - return rows.some((row) => { - const turn = readAgentJournalTurn(row.body) - return ( - turn !== null && - turn.state !== 'running' && - (turn.turnId === key || turn.turnId.endsWith(`:${providerTimelineKeyPart(key)}`)) - ) - }) -} - -export function acpJournalTurnHasUser( - rows: readonly AgentJournalRenderItem[], - key: string -): boolean { - const record = rows.find((row) => acpJournalTurnKey(row) === key) - if (!record) { - return false - } - const turn = readAgentJournalTurn(record.body) - return rows.some( - (row) => - row.body.kind === 'message' && - row.body.role === 'user' && - (row.itemId === turn?.userItemId || - (row.turnScope?.kind === 'turn' && row.turnScope.turnItemId === record.itemId)) - ) -} diff --git a/src/main/acp/acp-replay-user-messages.ts b/src/main/acp/acp-replay-user-messages.ts index 8b8aca36c145..950a1d5aab5a 100644 --- a/src/main/acp/acp-replay-user-messages.ts +++ b/src/main/acp/acp-replay-user-messages.ts @@ -1,14 +1,9 @@ -import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' -import type { - AgentJournalMessageItem, - AgentJournalRenderItem -} from '../../shared/agent-session-journal-types' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' -import { acpJournalTurnHasUser } from './acp-journal-turns' import { acpPromptClientMessageId } from './acp-prompt-turns' import type { SessionUpdate } from './generated/acp-protocol.generated' @@ -49,23 +44,19 @@ export class AcpReplayUserMessages { } } -/** The user message of a replayed turn the journal lacks. A turn Orca's own prompt began whose send - * the journal holds takes that send as its opener, so the bubble is not written twice. */ +/** The user message of a replayed turn. The journal decides at the write whether the turn already + * holds it, so a send Orca journaled is never written twice; see `input.replayed`. */ export function acpReplayedUser( - rows: readonly AgentJournalRenderItem[], join: { thread: string; turn: string }, - body: AgentJournalMessageItem -): ProviderTimelineEvent[] { - if (acpJournalTurnHasUser(rows, join.turn)) { - return [] - } + body: AgentJournalMessageItem, + messageId = '' +): ProviderTimelineEvent { const clientMessageId = acpPromptClientMessageId(join.turn) - const sent = - clientMessageId === undefined - ? undefined - : rows.find((row) => row.itemId === agentJournalSubmissionKey(clientMessageId)) - if (clientMessageId !== undefined && sent) { - return [{ type: 'input.accepted', clientMessageId, requestedAt: sent.observedAt, join }] + return { + type: 'input.replayed', + item: `replay-user:${join.turn}:${messageId}`, + body, + join, + ...(clientMessageId === undefined ? {} : { clientMessageId }) } - return [{ type: 'item.update', item: `replay-user:${join.turn}:`, body, join }] } diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index c398c129dcd6..4339243cf108 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -7,6 +7,7 @@ import { import { acpWindowUsage } from './acp-context-usage' import type { AcpDialect } from './acp-dialects/acp-dialect' import type { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' +import { acpReplayedUser } from './acp-replay-user-messages' import type { AcpToolTimeline } from './acp-tool-timeline' import type { SessionNotification } from './generated/acp-protocol.generated' @@ -55,21 +56,26 @@ export function acpSessionUpdate( } ] : [{ type: 'provider.frame', frameKind: update.sessionUpdate, payload: update, ...join }] - case 'user_message_chunk': - return replay && update.content.type === 'text' + case 'user_message_chunk': { + if (!replay || update.content.type !== 'text') { + return [] + } + const body: AgentJournalMessageItem = replayUserBody ?? { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: update.content.text }] + } + return turn === undefined ? [ { type: 'item.update', item: `replay-user:${turn}:${update.messageId ?? ''}`, - body: replayUserBody ?? { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: update.content.text }] - }, + body, ...join } ] - : [] + : [acpReplayedUser({ thread: notification.sessionId, turn }, body, update.messageId ?? '')] + } case 'tool_call': case 'tool_call_update': { const events = tools.translate(update, dialect, join.join) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index ca759ee0fceb..c0c964090cc4 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -12,7 +12,6 @@ import { type AcpDialect, type AcpRequestPresentation } from './acp-dialects/acp-dialect' -import { acpJournalTurnIsSettled } from './acp-journal-turns' import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' import { acpReplayedUser, AcpReplayUserMessages } from './acp-replay-user-messages' import type { AcpSessionEvent } from './acp-session-runtime' @@ -24,6 +23,20 @@ import { AcpTurnMessages } from './acp-turn-messages' import { SessionNotificationSchema, type PromptResponse } from './generated/acp-protocol.generated' const requestSessionSchema = z.object({ sessionId: z.string() }) + +/** Items from the provider's saved history say so, for the journal to decide what they add. */ +function acpMarkedReplay( + events: ProviderTimelineEvent[], + replay: boolean +): ProviderTimelineEvent[] { + return replay + ? events.map((event) => + event.type === 'item.open' || event.type === 'item.update' || event.type === 'item.close' + ? { ...event, replay: true } + : event + ) + : events +} type LoadReplay = { adopt: boolean turn?: string @@ -166,12 +179,14 @@ export class AcpTimelineTranslator { } const isReplay = extension?.replay ?? (markedReplay || this.replay !== undefined) const providerTurn = extension?.turn + // What the journal already holds of a replayed turn is decided at each write, against the + // journal then: a load runs before its sink can read the journal. if ( isReplay && this.replay && - (providerTurn - ? acpJournalTurnIsSettled(this.options.journalItems(), providerTurn) - : !this.replay.adopt && !this.dialect.injectedPromptIdentity) + !providerTurn && + !this.replay.adopt && + !this.dialect.injectedPromptIdentity ) { this.replay.pendingUser = undefined return [] @@ -223,11 +238,7 @@ export class AcpTimelineTranslator { this.replay.turn = turn if (this.replay.pendingUser) { events.push( - ...acpReplayedUser( - this.options.journalItems(), - { thread: this.options.sessionId, turn }, - this.replay.pendingUser - ) + acpReplayedUser({ thread: this.options.sessionId, turn }, this.replay.pendingUser) ) this.replay.pendingUser = undefined } @@ -253,32 +264,35 @@ export class AcpTimelineTranslator { this.prompts.current = undefined } } - return events + return acpMarkedReplay(events, isReplay) } if (standard?.success) { const messageKey = turn && this.dialect.injectedPromptIdentity ? this.messages.key(turn, standard.data.update) : undefined - return [ - ...events, - ...acpSessionUpdate( - standard.data, - turn, - at, - isReplay, - this.tools, - this.dialect, - this.backgroundTasks, - replayUser?.body, - messageKey - ) - ] + return acpMarkedReplay( + [ + ...events, + ...acpSessionUpdate( + standard.data, + turn, + at, + isReplay, + this.tools, + this.dialect, + this.backgroundTasks, + replayUser?.body, + messageKey + ) + ], + isReplay + ) } if (method === 'session/update') { events.push({ type: 'provider.frame', frameKind: method, payload: params, join }) } - return events + return acpMarkedReplay(events, isReplay) } request( diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts index af1918c24b1c..f4515b96a214 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -237,6 +237,7 @@ export function createProviderTimelineAssembler( case 'provider.frame': return applyDecided({ ...event, minted: context.joins.mint('f') }) case 'input.accepted': + case 'input.replayed': case 'turn.end': case 'item.open': case 'item.update': diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts index ee13faf44a36..f7808f8afb67 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -4,6 +4,7 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { StructuredAgentSessionItemAppendOptions } from '../agent-session-wire/structured-agent-session-event-sink' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' @@ -99,3 +100,18 @@ export function settlementOf( } return providerTimelineSettlement(input.journal, { turnItemId: turn.itemId }, [turn], end) } + +/** A replayed event for a turn the provider itself completed: the journal holds that turn whole. + * Completion is final, so planning's possibly older journal never drops what execution would keep. */ +export function replaysCompletedTurn( + input: ProviderTimelineDecisionInput, + join: { turn?: string } | undefined +): boolean { + if (join?.turn === undefined || !input.journal) { + return false + } + const turn = input.context.joins.turn(providerKey(join.turn), input.state.namespace) + return ( + readAgentJournalTurn(input.journal.itemBody(turn.itemId) ?? undefined)?.state === 'completed' + ) +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts index 7f03e7267c00..2622ca3056fb 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts @@ -21,6 +21,7 @@ import type { import { decideContextUsage, decideInput, + decideReplayedInput, decideSessionEnd, decideTurnEnd, decideTurnOpen @@ -42,6 +43,8 @@ export function decideProviderTimelineEvent( switch (event.type) { case 'input.accepted': return decideInput(input, event) + case 'input.replayed': + return decideReplayedInput(input, event) case 'turn.open': return decideTurnOpen(input, event) case 'turn.end': diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts index b3f01ff9f7e4..eed5eb671aec 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -28,6 +28,7 @@ import type { AgentSessionContextUsage } from '../../../shared/agent-session-con import type { AgentJournalApprovalItem, AgentJournalItemBody, + AgentJournalMessageItem, AgentJournalProducerLinkage, AgentJournalQuestionItem, AgentJournalTurnOutcome @@ -64,6 +65,12 @@ type Produced = { type Joined = { join?: ProviderTimelineJoin } +type Replayed = { + /** From the provider's saved history, as a load replays it: a turn the journal holds as the + * provider completed it keeps exactly what it holds; any other turn takes what it lacks. */ + replay?: true +} + export type ProviderTimelineEvent = /** Orca's send reached the provider. It names the open turn when nothing opened that one, else * the next. `join.item`: the provider's id for its echo of the send, which takes that message's @@ -74,6 +81,17 @@ export type ProviderTimelineEvent = requestedAt: number join?: ProviderTimelineJoin & { item?: string } } + /** The provider's saved copy of the message that opened `join.turn`, as a load replays it; `item` + * names its row, re-sent whole as it grows. The journal decides at the write: a turn that holds + * its user message keeps it, a journaled send `clientMessageId` becomes the turn's opener, and + * only otherwise is `body` written. */ + | { + type: 'input.replayed' + item: string + body: AgentJournalMessageItem + clientMessageId?: string + join: ProviderTimelineJoin & { turn: string } + } /** A turn began. `turn` is the provider's turn id when it has one; the assembler mints one otherwise. */ | { type: 'turn.open'; turn?: string; at: number } /** The provider ended a turn. Absent `turn` means the open turn. */ @@ -91,11 +109,17 @@ export type ProviderTimelineEvent = * message carrying a `background-task` block with its own run state — beside the tool call that * started it, which closes as usual: no turn's end settles that row; its own updates do, and the * session's end leaves one still in flight `unverifiable`. */ - | ({ type: 'item.open'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) + | ({ type: 'item.open'; item: string; body: ProviderTimelineItemBody } & Produced & + Joined & + Replayed) /** The item's whole current body. Content may be replaced; a settled tool never runs again. */ - | ({ type: 'item.update'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) + | ({ type: 'item.update'; item: string; body: ProviderTimelineItemBody } & Produced & + Joined & + Replayed) /** The item's whole terminal body; it replaces any text streamed into the same item. */ - | ({ type: 'item.close'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) + | ({ type: 'item.close'; item: string; body: ProviderTimelineItemBody } & Produced & + Joined & + Replayed) /** Streamed text. */ | ({ type: 'text.delta' diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts index d5eb0ffb62ca..bd9f807ea4cd 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts @@ -1,5 +1,7 @@ // What item, request and frame events do, decided on the forecast and again on the ledger. +import { isDeepStrictEqual } from 'node:util' + import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' import { unhandledProviderFrameJournalItem } from '../agent-session-wire/unhandled-provider-frame' @@ -8,6 +10,7 @@ import { providerTimelineEntryBytes, providerTimelinePlacement } from './provide import { pendingPrompt, providerKey, + replaysCompletedTurn, runningTool, settledTool, turnOf, @@ -23,6 +26,9 @@ export function decideItem( event: Extract ): ProviderTimelineDecision { const { state, journal, context } = input + if (event.replay && replaysCompletedTurn(input, event.join)) { + return { dropped: 'turn-replayed' } + } const change = event.type === 'item.open' ? 'open' : event.type === 'item.update' ? 'update' : 'close' const join: ProviderTimelineItemJoin = { @@ -48,6 +54,16 @@ export function decideItem( return { dropped: 'item-settled' } } const obligation = change !== 'close' && requiresTerminalSettlement(event.body) + // A snapshot of what the row already says, read at the write: planning's journal may lag the queue. + if ( + input.execute && + change === 'update' && + !obligation && + held && + isDeepStrictEqual(held, event.body) + ) { + return { dropped: 'item-replayed' } + } const placement = providerTimelinePlacement(context, state, event.join) const row = found ?? diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts index 35fd7abc14b5..6e548a790698 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts @@ -109,6 +109,16 @@ export function planProviderTimelineSlots( case 'context.usage': item(TURN_ROW_RESERVED_BYTES, true) return + case 'input.replayed': + // Either the turn row's new opener or the message itself. + item( + Math.max( + TURN_ROW_RESERVED_BYTES, + estimateStructuredAgentSessionItemBytes(IDENTITY_PLACEHOLDER, event.body) + ), + true + ) + return case 'item.open': case 'item.update': case 'item.close': diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts index a6ee99b15bf1..819ca754539f 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts @@ -16,6 +16,7 @@ import { } from './agent-journal-turn-row-revision' import { providerKey, + replaysCompletedTurn, settlementOf, type ProviderTimelineDecidedEvent, type ProviderTimelineDecision, @@ -23,6 +24,7 @@ import { type ProviderTimelineResolvedWrite } from './provider-timeline-decision' import { providerTimelinePlacement } from './provider-timeline-context' +import { decideItem } from './provider-timeline-item-decisions' import type { ProviderTimelineTurnRef } from './provider-timeline-joins' import { providerTimelineSettlement, @@ -167,6 +169,51 @@ export function decideInput( } } +/** Decided only at the write, against the journal then: what a load replays was journaled before + * that load's sink could read anything. */ +export function decideReplayedInput( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal, context } = input + if (replaysCompletedTurn(input, event.join)) { + return { dropped: 'turn-replayed' } + } + if (!input.execute || !journal) { + return {} + } + const turn = context.joins.turn(providerKey(event.join.turn), state.namespace) + const own = context.joins.find( + { family: 'item', key: providerKey(event.item), thread: event.join.thread ?? null }, + journal, + state.namespace + ) + const opener = readAgentJournalTurn(journal.itemBody(turn.itemId) ?? undefined)?.userItemId + const openerBody = opener === undefined ? null : journal.itemBody(opener) + if (opener !== own?.itemId && openerBody?.kind === 'message' && openerBody.role === 'user') { + return { dropped: 'item-replayed' } + } + const sent = + event.clientMessageId === undefined + ? null + : journal.item(agentJournalSubmissionKey(event.clientMessageId)) + if (sent && event.clientMessageId !== undefined && !own) { + return decideInput(input, { + type: 'input.accepted', + clientMessageId: event.clientMessageId, + requestedAt: sent.observedAt, + join: event.join + }) + } + return decideItem(input, { + type: 'item.update', + item: event.item, + body: event.body, + join: event.join, + replay: true + }) +} + /** Only the opener fields change; the rest are the row's as the journal holds it. */ export function reviseOpener( journal: StructuredAgentSessionTransitionJournal, From 72ee99ee8d63a810b6579bc9a78fb34d06b0a4e0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:17:43 -0700 Subject: [PATCH 102/269] fix(native-chat): a Grok start can be stopped at any step and keeps a child it could not stop A start is now an attempt registered before anything awaits: a close or an admitted Stop reaches it before the spawn (nothing is spawned), during the handshake (the child stops), and a start whose cleanup could not prove the child gone keeps that child, so every later close asks it again instead of answering for a child it forgot. A Stop naming an earlier turn, or one admission would not run now, leaves the start alone. --- ...ay-user-messages.ts => acp-load-replay.ts} | 17 ++ src/main/acp/acp-session-update.ts | 2 +- src/main/acp/acp-structured-acquire.ts | 11 + .../acp/acp-structured-host-lifecycle.test.ts | 270 ++++++++++++++++++ ...ructured-session-adapter-lifecycle.test.ts | 54 +++- ...-structured-session-adapter-resume.test.ts | 63 ++++ .../acp/acp-structured-session-adapter.ts | 96 ++++--- src/main/acp/acp-structured-starts.ts | 70 +++++ src/main/acp/acp-timeline-translator.ts | 15 +- ...structured-agent-session-host-mutations.ts | 40 ++- ...ctured-agent-session-mutation-admission.ts | 32 +++ 11 files changed, 613 insertions(+), 57 deletions(-) rename src/main/acp/{acp-replay-user-messages.ts => acp-load-replay.ts} (80%) create mode 100644 src/main/acp/acp-structured-host-lifecycle.test.ts create mode 100644 src/main/acp/acp-structured-starts.ts diff --git a/src/main/acp/acp-replay-user-messages.ts b/src/main/acp/acp-load-replay.ts similarity index 80% rename from src/main/acp/acp-replay-user-messages.ts rename to src/main/acp/acp-load-replay.ts index 950a1d5aab5a..3906536aa1a7 100644 --- a/src/main/acp/acp-replay-user-messages.ts +++ b/src/main/acp/acp-load-replay.ts @@ -1,3 +1,6 @@ +// What a load replays from the agent's saved history, as grammar events the journal reconciles at +// each write. + import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { @@ -60,3 +63,17 @@ export function acpReplayedUser( ...(clientMessageId === undefined ? {} : { clientMessageId }) } } + +/** Items from the provider's saved history say so, for the journal to decide what they add. */ +export function acpMarkedReplay( + events: ProviderTimelineEvent[], + replay: boolean +): ProviderTimelineEvent[] { + return replay + ? events.map((event) => + event.type === 'item.open' || event.type === 'item.update' || event.type === 'item.close' + ? { ...event, replay: true } + : event + ) + : events +} diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index 4339243cf108..0a500d81899f 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -7,7 +7,7 @@ import { import { acpWindowUsage } from './acp-context-usage' import type { AcpDialect } from './acp-dialects/acp-dialect' import type { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' -import { acpReplayedUser } from './acp-replay-user-messages' +import { acpReplayedUser } from './acp-load-replay' import type { AcpToolTimeline } from './acp-tool-timeline' import type { SessionNotification } from './generated/acp-protocol.generated' diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index cf7514a7d4f0..2da61b88071c 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -58,6 +58,8 @@ export async function acquireAcpStructuredSession(input: { acquire: StructuredAgentSessionAcquireInput deps: AcpStructuredSessionAdapterDeps generation: string + /** A close reached this start; checked until the spawn, after which `track` hands it the child. */ + abandoned: () => boolean /** Registers the child so a close during the acquire can stop it. */ track: (child: AcpStructuredChild) => void /** The child's exit, observed while or after the session exists. */ @@ -77,11 +79,20 @@ export async function acquireAcpStructuredSession(input: { if (!sink) { throw new AgentSessionPreSpawnError(new Error(`${spec.agent} chats need a journal sink`)) } + const closedBeforeSpawn = () => { + if (input.abandoned()) { + throw new AgentSessionPreSpawnError( + new Error(`${acpAgentName(spec.agent)} was closed before it started`) + ) + } + } + closedBeforeSpawn() const launch: AcpStructuredLaunch = await deps .resolveLaunch({ identity: acquire.identity }) .catch((error: unknown) => { throw new AgentSessionPreSpawnError(error) }) + closedBeforeSpawn() const child = deps.spawnChild({ command: launch.command, args: launch.args, diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts new file mode 100644 index 000000000000..4d15b93cb568 --- /dev/null +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -0,0 +1,270 @@ +// The ACP adapter behind the real host: the host's own order (a fresh sink per start, bound only +// once the start proved its owner; Close and Stop reaching a start from outside the session's +// queue), with a scripted Grok, the real record store and an on-disk journal. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { agentSessionStoredAgents } from '../../shared/agent-session-stored-agent' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { + closeProviderTimelineRigs, + messageText +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { + CALLER, + envelope, + hostTestState, + replaceHostTestState +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { AgentSessionRecoveryCapsule } from '../runtime/agent-session-recovery-capsule' +import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' +import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { + GROK, + GROK_CONFIG_OPTIONS, + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk, + waitFor +} from './acp-structured-adapter.test-support' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const hello: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }] +} + +const attachParams = (fence: number | null = null) => + hostTestAttachParams(fence, { + provider: 'grok', + agent: 'grok', + accountHome: { variable: 'GROK_HOME', path: '/grok' }, + providerHandle: undefined + }) + +/** A launch that resumes the provider session once the chat has one. */ +function launch(resume: () => boolean): () => Promise { + return async () => ({ + spec: GROK, + command: '/fake/grok', + args: [], + cwd: '/workspace', + env: {}, + fullAccess: false, + resume: resume() ? { sessionId: PROVIDER_SESSION, replaceableKey: null } : null + }) +} + +async function openHostRig( + options: { + script?: (agent: AcpScriptedAgent) => void + deps?: Partial + } = {} +) { + const state = hostTestState() + const store = await openTestAgentSessionRecordStore(state.root, { + agents: agentSessionStoredAgents([ + { agent: 'grok', handleTransport: 'acp', accountHomeVariable: 'GROK_HOME' } + ]) + }) + let generation = 0 + const rig = await openAcpAdapterRig({ + ...options, + deps: { + now: () => HOST_TEST_NOW, + readProcessStartTime: async () => 1_700_000_000_000 + ++generation, + mintGeneration: () => `generation-${generation}`, + ...options.deps + } + }) + const host = new StructuredAgentSessionHost({ + logger: state.log.logger, + store, + adapter: rig.adapter, + journalDatabase: openTestJournalHostDatabase(state.root), + recoveryCapsule: new AgentSessionRecoveryCapsule(state.root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => HOST_TEST_NOW + }) + replaceHostTestState({ store, host }) + const fence = () => store.getRecord(SESSION)?.lease.runtimeFence ?? 1 + const messages = async () => + (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + .filter((row) => row.body.kind === 'message') + .map((row) => messageText(row.body)) + /** Orca's send of `hello` as `m1`, and Grok's reply `text`, ended or left running. */ + const exchange = async (text: string, end: boolean) => { + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + await journal.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, { + fence: fence(), + turnScope: { kind: 'thread' } + }) + await rig.adapter.dispatch({ + sessionId: SESSION, + clientMessageId: 'm1', + body: hello, + fence: fence() + }) + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:m1', text)) + if (end) { + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + } + await rig.settle() + await host.flushStreamedEvents(SESSION) + } + return { rig, host, store, fence, messages, exchange } +} + +/** On every load, Grok replays the exchange it saved: the user's `hello`, then `reply`. */ +function replays(reply: string, loads: { count: number }) { + return (agent: AcpScriptedAgent) => + agent.on('session/load', (frame) => { + loads.count += 1 + agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'hello' } }, + _meta: { isReplay: true } + }) + agent.notify('session/update', replyChunk('prompt:m1', reply, { isReplay: true })) + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) +} + +describe('resuming a Grok chat through the host', () => { + it('writes the user message once when Grok replays an exchange the journal holds', async () => { + const loads = { count: 0 } + let resumed = false + const { host, fence, messages, exchange } = await openHostRig({ + script: replays('hi', loads), + deps: { resolveLaunch: launch(() => resumed) } + }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + resumed = true + await exchange('hi', true) + expect(await messages()).toEqual(['hello', 'hi']) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await host.flushStreamedEvents(SESSION) + expect(loads.count).toBe(1) + expect(await messages()).toEqual(['hello', 'hi']) + await host.close(SESSION, 'user-close') + }) + + it('recovers the reply Grok saved past what an unfinished turn journaled, and keeps its end', async () => { + let resumed = false + const { host, fence, messages, exchange } = await openHostRig({ + script: replays('complete saved reply', { count: 0 }), + deps: { resolveLaunch: launch(() => resumed) } + }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + resumed = true + await exchange('complete', false) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await host.flushStreamedEvents(SESSION) + expect(await messages()).toEqual(['hello', 'complete saved reply']) + const rows = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + // The replay recovers content; it never says how the turn ended. + expect(rows.flatMap((row) => readAgentJournalTurn(row.body)?.state ?? [])).not.toContain( + 'running' + ) + await host.close(SESSION, 'user-close') + }) +}) + +describe('closing or stopping a Grok chat while it starts', () => { + it('keeps a failed start whose child is not proven gone, so a later close asks it again', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}), + deps: { startupTimeoutMs: 30 } + }) + const attaching = host.attach(CALLER, attachParams()).catch((error: unknown) => error) + await rig.frame('initialize') + const child = rig.child() + child.close = vi.fn(async () => false) + await host.close(SESSION, 'user-close') + expect(await attaching).toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) + expect(child.exited).toBe(false) + expect(await rig.adapter.closeSession(SESSION)).toBe(false) + child.close = vi.fn(async () => { + child.exit() + return true + }) + await host.close(SESSION, 'user-close') + expect(child.close).toHaveBeenCalled() + expect(child.exited).toBe(true) + expect(await rig.adapter.closeSession(SESSION)).toBe(true) + }) + + it('lets a Stop reach a child Grok never finished initializing', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}), + deps: { startupTimeoutMs: 60_000 } + }) + const attaching = host.attach(CALLER, attachParams()) + await rig.frame('initialize') + const stopping = host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + // Long before the startup bound, and with no close behind it. + await waitFor(() => expect(rig.child().exited).toBe(true)) + expect((await attaching).ok).toBe(false) + expect(await stopping).toMatchObject({ ok: true }) + expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) + }) + + it('leaves a start alone for a Stop that names a turn of a child already gone', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}), + deps: { startupTimeoutMs: 60_000 } + }) + const attaching = host.attach(CALLER, attachParams()) + await rig.frame('initialize') + const stopping = host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId: 'turn-of-an-old-child' }), + turnId: 'turn-of-an-old-child' + }) + await rig.settle() + expect(rig.child().closes).toBe(0) + await host.close(SESSION, 'user-close') + await attaching + await stopping + }) + + it('never spawns a child for a start closed while its launch was still resolving', async () => { + let releaseLaunch: () => void = () => {} + const resolving = { started: false } + const { rig, host } = await openHostRig({ + deps: { + resolveLaunch: async () => { + resolving.started = true + await new Promise((resolve) => { + releaseLaunch = resolve + }) + return launch(() => false)() + } + } + }) + const attaching = host.attach(CALLER, attachParams()).catch((error: unknown) => error) + await waitFor(() => expect(resolving.started).toBe(true)) + const closing = host.close(SESSION, 'user-close') + releaseLaunch() + expect(await attaching).toMatchObject({ name: 'AgentSessionPreSpawnError' }) + await closing + expect(rig.spawned).toEqual([]) + expect(await rig.adapter.closeSession(SESSION)).toBe(true) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index 889426e30d54..d864383cd44e 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { closeProviderTimelineRigs, @@ -7,7 +7,9 @@ import { } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { StructuredAgentSessionTaskQueue } from '../native-chat/agent-session-wire/structured-agent-session-task-queue' import { AcpStartupTimeoutError } from './acp-structured-acquire' +import { tick } from './acp-scripted-agent.test-support' import { + GROK, openAcpAdapterRig, PROVIDER_SESSION, replyChunk, @@ -272,6 +274,56 @@ describe('ACP startup that never answers', () => { expect(rig.child().closes).toBe(1) await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) }) + + it('keeps the child of a failed start until its exit is proven, and starts no other meanwhile', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => agent.on('initialize', () => {}), + deps: { startupTimeoutMs: 15 } + }) + const failed = rig.acquire().catch((error: unknown) => error) + await rig.frame('initialize') + const child = rig.child() + child.close = vi.fn(async () => false) + await rig.adapter.abandonStart(SESSION) + expect(await failed).toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) + // Every later stop asks the child again, and none answers for it. + expect(await rig.adapter.closeSession(SESSION)).toBe(false) + await expect(rig.adapter.closeAll()).rejects.toThrow('could not be proven stopped') + expect(await rig.acquire().catch((error: unknown) => error)).toMatchObject({ + name: 'AgentSessionAcquisitionExitUnprovenError' + }) + expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) + child.exit() + expect(await rig.adapter.closeSession(SESSION)).toBe(true) + }) + + it('spawns nothing for a start a close reached before its spawn', async () => { + let releaseLaunch: () => void = () => {} + const rig = await openAcpAdapterRig({ + deps: { + resolveLaunch: async () => { + await new Promise((resolve) => { + releaseLaunch = resolve + }) + return { + spec: GROK, + command: '/opt/grok/bin/grok', + args: [], + cwd: '/workspace/project', + env: {}, + fullAccess: false, + resume: null + } + } + } + }) + const failed = rig.acquire().catch((error: unknown) => error) + await tick() + await rig.adapter.abandonStart(SESSION) + releaseLaunch() + expect(await failed).toMatchObject({ name: 'AgentSessionPreSpawnError' }) + expect(rig.spawned).toEqual([]) + }) }) describe('ACP session release', () => { diff --git a/src/main/acp/acp-structured-session-adapter-resume.test.ts b/src/main/acp/acp-structured-session-adapter-resume.test.ts index 60678d2dc72d..b7666f2ccdc5 100644 --- a/src/main/acp/acp-structured-session-adapter-resume.test.ts +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -7,6 +7,8 @@ import { messageText, SESSION } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' import { GROK_CONFIG_OPTIONS, @@ -67,6 +69,67 @@ describe('ACP resume reconciles what the agent replays against the journal', () expect(await rig.rig.rows()).toEqual(before) }) + it('takes the rest of a reply Grok saved for a turn that ended without its end', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + script: replaysOnReload('prompt:m1', 'complete saved reply') + }) + await rig.acquire() + await rig.rig.eventSink.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, { + turnScope: { kind: 'thread' } + }) + await sendHello(rig, 'm1') + await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:m1', 'complete')) + await rig.settle() + // The child goes mid-reply: the turn is left unverifiable, not completed. + await rig.adapter.closeSession(SESSION) + await rig.acquire({ fence: 2 }) + await rig.settle() + const rows = await rig.rig.rows() + expect(rows.flatMap((row) => messageText(row.body) ?? [])).toEqual([ + 'hello', + 'complete saved reply' + ]) + expect(rows.flatMap((row) => readAgentJournalTurn(row.body)?.state ?? [])).toEqual([ + 'unverifiable' + ]) + }) + + it('writes nothing for a completed turn replayed into a sink bound only after the load', async () => { + const rig = await openAcpAdapterRig({ ...resume, script: replaysOnReload('prompt:m1', 'hi') }) + await rig.acquire() + await rig.rig.eventSink.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, { + turnScope: { kind: 'thread' } + }) + await sendHello(rig, 'm1') + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:m1', 'hi')) + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + await rig.settle() + await rig.adapter.closeSession(SESSION) + const before = await rig.rig.rows() + // The host's order: a fresh sink per start, bound to the journal once the start succeeded. + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging(SESSION)) + await rig.adapter.acquire({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: null + }, + fence: 2, + spawnToken: 'spawn-2', + events: deferred.sink + }) + deferred.bind({ journal: rig.rig.journal, fence: 2, publish: () => {} }) + expect(await deferred.drained()).toMatchObject({ ok: true }) + expect(await rig.rig.rows()).toEqual(before) + await rig.adapter.closeSession(SESSION) + deferred.close() + }) + it('adopts a reply Grok saved that Orca never wrote, joined to the send already in the journal', async () => { const rig = await openAcpAdapterRig({ ...resume, diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index f7dfb41b8e04..fc7e064b13d5 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -10,6 +10,7 @@ import type { AgentSessionExecutionLocation } from '../../shared/agent-session-r import { AgentSessionAcquisitionExitProvenError, AgentSessionAcquisitionExitUnprovenError, + AgentSessionPreSpawnError, isAgentSessionPreSpawnError, type AgentSessionAcquisition, type AgentSessionCancelOutcome, @@ -21,25 +22,22 @@ import { import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' import { withObservedProviderExit } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { acpAgentName, acquireAcpStructuredSession } from './acp-structured-acquire' -import type { AcpStructuredChild } from './acp-structured-child' import { closeAcpSessionJournal, endAcpStructuredSession, type AcpStructuredSession } from './acp-structured-session' +import { AcpStructuredStarts, type AcpStartAttempt } from './acp-structured-starts' import { ACP_CANCEL_TIMEOUT_MS, type AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' import type { ContentBlock } from './generated/acp-protocol.generated' -type StartingChild = { child: AcpStructuredChild; abandoned: boolean } - export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter { /** Live children, and ones whose exit is not yet proven; a proven exit removes its entry. */ private readonly sessions = new Map() - /** Children still starting, so a close need not wait behind their acquire to stop them. */ - private readonly starting = new Map() + private readonly starts = new AcpStructuredStarts() constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {} @@ -49,24 +47,34 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte async acquire(input: StructuredAgentSessionAcquireInput): Promise { const sessionId = input.identity.sessionId - if (!(await this.closeSession(sessionId))) { - throw new AgentSessionAcquisitionExitUnprovenError( - new Error( - `the previous ${this.deps.spec.agent} child for ${sessionId} could not be stopped` + const attempt = this.starts.begin(sessionId) + try { + if (!(await this.stopPrevious(sessionId))) { + throw new AgentSessionAcquisitionExitUnprovenError( + new Error( + `the previous ${this.deps.spec.agent} child for ${sessionId} could not be stopped` + ) ) - ) + } + return await this.start(input, attempt) + } finally { + this.starts.end(sessionId, attempt) } + } + + private async start( + input: StructuredAgentSessionAcquireInput, + attempt: AcpStartAttempt + ): Promise { + const sessionId = input.identity.sessionId const generation = this.deps.mintGeneration?.() ?? randomUUID() - const tracked: { starting: StartingChild | null } = { starting: null } try { const { acquisition, session } = await acquireAcpStructuredSession({ acquire: input, deps: this.deps, generation, - track: (child) => { - tracked.starting = { child, abandoned: false } - this.starting.set(sessionId, tracked.starting) - }, + abandoned: () => attempt.abandoned, + track: (child) => this.starts.track(attempt, child), onExit: (session) => { if (session && this.sessions.get(sessionId) === session) { this.finish(session, this.now()) @@ -76,24 +84,26 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte onSettled: (settlement) => this.deps.onDispatchSettledLate?.({ sessionId, ...settlement }), forceClose: (id) => void this.forceCloseSession(id) }) - if (tracked.starting?.abandoned) { + if (attempt.abandoned) { session.lane.dispose() throw new Error('closed while starting') } this.sessions.set(sessionId, session) return acquisition } catch (error) { - const child = tracked.starting?.child - const abandoned = tracked.starting?.abandoned === true + const { child } = attempt // Checked before the close below, which would make any exit look like one Orca asked for. - const exitedOnItsOwn = child?.exited === true && !abandoned + const exitedOnItsOwn = child?.exited === true && !attempt.abandoned if (child && !(await child.close().catch(() => false))) { + this.starts.retainFailed(sessionId, child) throw new AgentSessionAcquisitionExitUnprovenError(error) } - if (abandoned) { - throw new Error(`${acpAgentName(this.deps.spec.agent)} was closed while starting`, { - cause: error - }) + if (attempt.abandoned) { + const closed = new Error( + `${acpAgentName(this.deps.spec.agent)} was closed while starting`, + { cause: error } + ) + throw child ? closed : new AgentSessionPreSpawnError(closed) } if (child && exitedOnItsOwn && !isAgentSessionPreSpawnError(error)) { // The agent's own last words are what a person can act on. @@ -102,21 +112,14 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte ) } throw error - } finally { - if (tracked.starting && this.starting.get(sessionId) === tracked.starting) { - this.starting.delete(sessionId) - } } } - /** A close that must not wait behind an acquire still starting: its child is stopped now, and - * that acquire fails through the start-failure surface. */ + /** A close that must not wait behind an acquire still starting: that start stops now, its child + * if it has one, and the acquire fails through the start-failure surface. A failed start's + * child still unproven gone is asked again. */ abandonStart = async (sessionId: string): Promise => { - const starting = this.starting.get(sessionId) - if (starting) { - starting.abandoned = true - await starting.child.close() - } + await Promise.all([this.starts.abandon(sessionId), this.starts.stopFailed(sessionId)]) } async dispatch(input: { @@ -249,19 +252,32 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte forceCloseSession = (sessionId: string): Promise => this.stop(sessionId, false) async closeAll(): Promise { - const ids = new Set([...this.sessions.keys(), ...this.starting.keys()]) + const ids = new Set([...this.sessions.keys(), ...this.starts.sessionIds()]) const proven = await Promise.all([...ids].map((sessionId) => this.closeSession(sessionId))) if (proven.includes(false)) { throw new Error('an ACP agent child could not be proven stopped') } } - /** True only once the child is proven gone, or when this adapter runs none for the session. */ + /** True only once every child is proven gone, or when this adapter runs none for the session. */ private async stop(sessionId: string, requested: boolean): Promise { - const starting = this.starting.get(sessionId) - if (starting && !(await starting.child.close())) { - return false - } + const [abandoned, previous] = await Promise.all([ + this.starts.abandon(sessionId), + this.stopPrevious(sessionId, requested) + ]) + return abandoned && previous + } + + /** What an earlier start left: a failed start's child, and the session's own. */ + private async stopPrevious(sessionId: string, requested = true): Promise { + const [failedStart, session] = await Promise.all([ + this.starts.stopFailed(sessionId), + this.stopSession(sessionId, requested) + ]) + return failedStart && session + } + + private async stopSession(sessionId: string, requested: boolean): Promise { const session = this.sessions.get(sessionId) if (!session || session.ended) { return true diff --git a/src/main/acp/acp-structured-starts.ts b/src/main/acp/acp-structured-starts.ts new file mode 100644 index 000000000000..983810bef0e6 --- /dev/null +++ b/src/main/acp/acp-structured-starts.ts @@ -0,0 +1,70 @@ +// The adapter's starts: each acquire from its first step, so a close reaches it at any point (before +// the spawn too), and each failed start's child until its exit is proven, so a later close retries +// it instead of answering for a child it no longer knows. + +import type { AcpStructuredChild } from './acp-structured-child' + +export type AcpStartAttempt = { abandoned: boolean; child: AcpStructuredChild | null } + +export class AcpStructuredStarts { + private readonly starting = new Map() + private readonly failed = new Map() + + /** Registered before anything awaits, so a close from here on stops this start. */ + begin(sessionId: string): AcpStartAttempt { + const attempt: AcpStartAttempt = { abandoned: false, child: null } + this.starting.set(sessionId, attempt) + return attempt + } + + /** The start has its child; one a close already reached goes as soon as it exists. */ + track(attempt: AcpStartAttempt, child: AcpStructuredChild): void { + attempt.child = child + if (attempt.abandoned) { + void child.close().catch(() => false) + } + } + + end(sessionId: string, attempt: AcpStartAttempt): void { + if (this.starting.get(sessionId) === attempt) { + this.starting.delete(sessionId) + } + } + + /** The start under way, stopped: true once it has no child or that child's exit is proven. */ + async abandon(sessionId: string): Promise { + const attempt = this.starting.get(sessionId) + if (!attempt) { + return true + } + attempt.abandoned = true + return attempt.child ? attempt.child.close().catch(() => false) : true + } + + /** A failed start whose child is not proven gone keeps it until its exit is. */ + retainFailed(sessionId: string, child: AcpStructuredChild): void { + this.failed.set(sessionId, child) + child.onExit(() => { + if (this.failed.get(sessionId) === child) { + this.failed.delete(sessionId) + } + }) + } + + /** Asks a failed start's child to stop again: true once none is left unproven. */ + async stopFailed(sessionId: string): Promise { + const child = this.failed.get(sessionId) + if (!child) { + return true + } + const proven = await child.close().catch(() => false) + if (proven && this.failed.get(sessionId) === child) { + this.failed.delete(sessionId) + } + return proven + } + + sessionIds(): string[] { + return [...this.starting.keys(), ...this.failed.keys()] + } +} diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index c0c964090cc4..dd86e27857d0 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -13,7 +13,7 @@ import { type AcpRequestPresentation } from './acp-dialects/acp-dialect' import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' -import { acpReplayedUser, AcpReplayUserMessages } from './acp-replay-user-messages' +import { acpMarkedReplay, acpReplayedUser, AcpReplayUserMessages } from './acp-load-replay' import type { AcpSessionEvent } from './acp-session-runtime' import { translateAcpRequest } from './acp-timeline-requests' export { acpTurnEnd } from './acp-prompt-turns' @@ -24,19 +24,6 @@ import { SessionNotificationSchema, type PromptResponse } from './generated/acp- const requestSessionSchema = z.object({ sessionId: z.string() }) -/** Items from the provider's saved history say so, for the journal to decide what they add. */ -function acpMarkedReplay( - events: ProviderTimelineEvent[], - replay: boolean -): ProviderTimelineEvent[] { - return replay - ? events.map((event) => - event.type === 'item.open' || event.type === 'item.update' || event.type === 'item.close' - ? { ...event, replay: true } - : event - ) - : events -} type LoadReplay = { adopt: boolean turn?: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 443882c93cb1..a8570f791ab0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -34,8 +34,10 @@ import { cancelPlan, promptPlan, sendPlan, - setOptionPlan + setOptionPlan, + type MutationPlan } from './structured-agent-session-mutation-plans' +import { agentSessionMutationAdmitsNow } from './structured-agent-session-mutation-admission' import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import { mutateWithChatStop } from './structured-agent-session-chat-stop' @@ -106,6 +108,9 @@ export function cancelStructuredAgentSessionTurn( } const plan = cancelPlan(params) const { prompt } = params + if (!prompt && params.turnId === undefined) { + abandonStartForStop(context, caller, params.envelope, plan) + } // A card's Cancel stops whatever the chat has in flight, as the Stop button does; it reaches the // Stop only for a card the live turn raised (`cancelStructuredAgentSessionPrompt`). const stopped = prompt ? { envelope: params.envelope } : params @@ -120,6 +125,39 @@ export function cancelStructuredAgentSessionTurn( ) } +/** A Stop must not wait behind a start the provider may never answer: the start the session's + * queue is waiting on stops now, as a close's does, and the Stop's own step then finds no child. + * Only a Stop admission would run now; one naming a turn is about a child already gone, so it + * leaves a newer start alone. */ +function abandonStartForStop( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + envelope: AgentSessionMutationEnvelope, + plan: MutationPlan +): void { + const { adapter, store, logger } = context.deps + const { sessionId } = envelope + if ( + !adapter.abandonStart || + !agentSessionMutationAdmitsNow({ + store, + callerKey: caller.callerKey, + envelope, + plan, + now: context.now + }) + ) { + return + } + void adapter.abandonStart(sessionId).catch((error: unknown) => + logger.warn('stopping a starting provider for a Stop failed', { + scope: 'abandon-start', + sessionId, + error + }) + ) +} + export function respondToStructuredAgentSessionPrompt( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 5bb072f25946..62406c32901c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -197,6 +197,38 @@ export async function admitAndRunAgentSessionMutation( : refuseAgentSessionMutation(outcome.refusal) } +/** Whether admission would run `plan` for the first time now, placing nothing: for an effect that + * must not wait in the session's queue for its turn. */ +export function agentSessionMutationAdmitsNow( + request: Pick, 'store' | 'callerKey' | 'envelope' | 'now'> & { + plan: Pick, 'method' | 'fields' | 'operationIdScope' | 'conversationWrite'> + } +): boolean { + const { plan, envelope } = request + const hostFingerprint = computeAgentSessionPayloadFingerprint({ + method: plan.method, + sessionId: envelope.sessionId, + fields: plan.fields + }) + const evaluated = request.store.evaluateMutationOperation({ + callerKey: request.callerKey, + envelope, + hostFingerprint, + now: request.now(), + ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}) + }) + return ( + evaluated !== null && + admitAgentSessionMutation({ + envelope, + hostFingerprint, + ledger: evaluated.decision, + lease: evaluated.record.lease, + ...(plan.conversationWrite ? { conversationWrite: true } : {}) + }).decision === 'admit' + ) +} + /** The committed ledger's admission, placing nothing: a failed commit left memory as it was. */ function admitWithoutLedgerRow( { store, logger }: Pick, 'store' | 'logger'>, From 934ecaeaad8183267df390d2b6cd4f997bcc252c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:24:50 -0700 Subject: [PATCH 103/269] test(native-chat): a replayed user message and a recovered reply are decided at the write --- ...-timeline-assembler-replayed-input.test.ts | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replayed-input.test.ts diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replayed-input.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replayed-input.test.ts new file mode 100644 index 000000000000..a315b16038d6 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replayed-input.test.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + assistantText, + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + openUnboundProviderTimelineAssembler, + type ProviderTimelineRig +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const user = (text: string): AgentJournalMessageItem => ({ + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text }] +}) + +async function userMessages(rig: ProviderTimelineRig): Promise<(string | undefined)[]> { + return (await rig.rows()) + .filter((row) => row.body.kind === 'message' && row.body.role === 'user') + .map((row) => messageText(row.body)) +} + +/** Orca's send `m1`, journaled, opening turn `t1`; the child then went without ending it. */ +async function sendOpenedTurn(rig: ProviderTimelineRig): Promise { + rig.eventSink.appendItem({ provider: 'orca', clientMessageId: 'm1' }, user('hello'), { + turnScope: { kind: 'thread' } + }) + rig.assembler.apply({ + type: 'input.accepted', + clientMessageId: 'm1', + requestedAt: 900, + join: { turn: 't1' } + }) + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'session.ended', verdict: { state: 'unverifiable' } }) + await rig.rows() +} + +describe('a replayed user message is decided against the journal at the write', () => { + it('writes nothing for a turn the send opened, through a sink bound only after the load', async () => { + const rig = await openProviderTimelineRig() + await sendOpenedTurn(rig) + const load = openUnboundProviderTimelineAssembler(rig.journal) + // A copy the provider names by its own id, so only the turn's opener says it is the send. + load.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + load.assembler.apply({ + type: 'input.replayed', + item: 'replay-user:t1:provider-message-7', + body: user('hello'), + join: { turn: 't1' } + }) + await load.bind() + expect(await userMessages(rig)).toEqual(['hello']) + }) + + it('makes the journaled send the opener of a turn that never named it', async () => { + const rig = await openProviderTimelineRig() + rig.eventSink.appendItem({ provider: 'orca', clientMessageId: 'm1' }, user('hello'), { + turnScope: { kind: 'thread' } + }) + // The turn opened, then the host went before the send's echo reached it. + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + await rig.rows() + const load = openUnboundProviderTimelineAssembler(rig.journal) + load.assembler.apply({ + type: 'input.replayed', + item: 'replay-user:t1:', + body: user('hello'), + clientMessageId: 'm1', + join: { turn: 't1' } + }) + await load.bind() + expect(await userMessages(rig)).toEqual(['hello']) + expect(await rig.turn('t1')).toMatchObject({ userItemId: agentJournalSubmissionKey('m1') }) + }) + + it('writes the message of a turn that has none, and grows that row as it is resent', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + for (const text of ['hel', 'hello']) { + rig.assembler.apply({ + type: 'input.replayed', + item: 'replay-user:t1:', + body: user(text), + join: { turn: 't1' } + }) + } + expect(await userMessages(rig)).toEqual(['hello']) + }) + + it('leaves a turn the provider completed exactly as the journal holds it', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.update', item: 'reply', body: assistantText('short') }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + const before = await rig.rows() + const load = openUnboundProviderTimelineAssembler(rig.journal) + load.assembler.apply({ + type: 'input.replayed', + item: 'replay-user:t1:', + body: user('hello'), + join: { turn: 't1' } + }) + load.assembler.apply({ + type: 'item.update', + item: 'reply', + body: assistantText('the provider saved more'), + join: { turn: 't1' }, + replay: true + }) + await load.bind() + expect(await rig.rows()).toEqual(before) + }) + + it('takes a reply the journal lacks once, and leaves it as it is on the next replay', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.update', item: 'reply', body: assistantText('part') }) + rig.assembler.apply({ type: 'session.ended', verdict: { state: 'unverifiable' } }) + await rig.rows() + const replay = { + type: 'item.update', + item: 'reply', + body: assistantText('part and the rest'), + join: { turn: 't1' }, + replay: true + } as const + const first = openUnboundProviderTimelineAssembler(rig.journal) + first.assembler.apply(replay) + await first.bind() + const recovered = await rig.rows() + expect(recovered.map((row) => messageText(row.body))).toContain('part and the rest') + const second = openUnboundProviderTimelineAssembler(rig.journal) + second.assembler.apply(replay) + await second.bind() + expect(await rig.rows()).toEqual(recovered) + }) +}) From 2258193d34cc8cd704a7fb054f4b539c0d3c521d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:32:40 -0700 Subject: [PATCH 104/269] test(native-chat): a Stop during a queued send's start cancels the send and leaves no failure row --- .../acp/acp-structured-host-lifecycle.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index 4d15b93cb568..ac02fa2d2cee 100644 --- a/src/main/acp/acp-structured-host-lifecycle.test.ts +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -226,6 +226,41 @@ describe('closing or stopping a Grok chat while it starts', () => { expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) }) + it('cancels a queued send whose start a Stop ended, with no start failure in the chat', async () => { + let spawns = 0 + const { rig, host } = await openHostRig({ + script: (agent) => { + spawns += 1 + if (spawns > 1) { + agent.on('initialize', () => {}) + } + }, + deps: { startupTimeoutMs: 60_000 } + }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + // The chat closes; the next send has to start Grok again, and that start never answers. + await host.close(SESSION, 'user-close') + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: hello }), + body: hello + }) + await waitFor(() => expect(spawns).toBe(2)) + await rig.frame('initialize') + const restarted = rig.child() + expect( + await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + ).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(restarted.exited).toBe(true) + await host.flushStreamedEvents(SESSION) + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + expect(journal.submissions()).toMatchObject([ + { dispatchState: 'rejected', rejection: { kind: 'cancelled' } } + ]) + const rows = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + expect(rows.filter((row) => row.body.kind === 'status')).toEqual([]) + await host.close(SESSION, 'user-close') + }) + it('leaves a start alone for a Stop that names a turn of a child already gone', async () => { const { rig, host } = await openHostRig({ script: (agent) => agent.on('initialize', () => {}), From 29d733a8eb5084b1aa9bb15fd707b3d9981d3525 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:43:42 -0700 Subject: [PATCH 105/269] fix(agent-permissions): match a two-token bypass flag as a token sequence The Agent Permissions toggle writes Grok's Yolo as "--permission-mode bypassPermissions". The structured launch compared that whole string against single tokens, so full access never applied and --always-approve was never passed. Every agent whose bypass is a flag and a value (Grok, Devin, Cline, ZCode, Qwen Code, ...) had the same miss in worktree ps. --- src/shared/tui-agent-launch-defaults.test.ts | 26 +++++++++++++++++++- src/shared/tui-agent-launch-defaults.ts | 8 ++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/shared/tui-agent-launch-defaults.test.ts b/src/shared/tui-agent-launch-defaults.test.ts index a99e4451a159..1a21ccf71ce2 100644 --- a/src/shared/tui-agent-launch-defaults.test.ts +++ b/src/shared/tui-agent-launch-defaults.test.ts @@ -4,6 +4,8 @@ import { resolveTuiAgentLaunchArgs, tuiAgentArgsBypassPermissions } from './tui-agent-launch-defaults' +import { applyAgentPermissionMode, YOLO_TUI_AGENT_ARGS } from './tui-agent-permissions' +import type { TuiAgent } from './tui-agent' describe('tuiAgentArgsBypassPermissions', () => { // The Agent Permissions toggle has no storage of its own: Yolo is the presence of the agent's @@ -25,7 +27,16 @@ describe('tuiAgentArgsBypassPermissions', () => { ['codex', '--dangerously-bypass-approvals-and-sandbox --model gpt-5.6-sol', true], ['codex', '--model gpt-5.6-sol', false], ['codex', '--config "note=--dangerously-bypass-approvals-and-sandbox only as text"', false], - ['codex', '-- --dangerously-bypass-approvals-and-sandbox', false] + ['codex', '-- --dangerously-bypass-approvals-and-sandbox', false], + // Two-token bypass flags match as a token sequence, not one token. + ['grok', '--permission-mode bypassPermissions', true], + ['grok', '--model grok-4.7 --permission-mode bypassPermissions', true], + ['grok', '--permission-mode default', false], + ['grok', '--permission-mode', false], + ['grok', '--permission-mode "bypassPermissions now"', false], + ['grok', '-- --permission-mode bypassPermissions', false], + ['devin', '--permission-mode bypass --respect-workspace-trust false', true], + ['devin', '--permission-mode bypass', false] ] as const)('reads %s args %s as %s', (agent, args, expected) => { expect(tuiAgentArgsBypassPermissions(agent, args, 'posix')).toBe(expected) }) @@ -80,6 +91,19 @@ describe('tuiAgentArgsBypassPermissions', () => { }) describe('resolvedTuiAgentArgsBypassPermissions', () => { + // The settings shape the Agent Permissions toggle writes, for every agent it covers. + it.each(Object.keys(YOLO_TUI_AGENT_ARGS) as TuiAgent[])( + "reads the toggle's own Yolo and Manual writes for %s", + (agent) => { + for (const platform of ['darwin', 'win32'] as const) { + const yolo = applyAgentPermissionMode({ mode: 'yolo' }) + expect(resolvedTuiAgentArgsBypassPermissions(agent, yolo, platform)).toBe(true) + const manual = applyAgentPermissionMode({ mode: 'manual' }) + expect(resolvedTuiAgentArgsBypassPermissions(agent, manual, platform)).toBe(false) + } + } + ) + it('fails closed when the configured arguments cannot be tokenized', () => { expect( resolvedTuiAgentArgsBypassPermissions( diff --git a/src/shared/tui-agent-launch-defaults.ts b/src/shared/tui-agent-launch-defaults.ts index 0cd9c0475c02..5574f31bfc9d 100644 --- a/src/shared/tui-agent-launch-defaults.ts +++ b/src/shared/tui-agent-launch-defaults.ts @@ -47,12 +47,16 @@ export function tuiAgentArgsBypassPermissions( return false } const tokenized = tokenizeStartupCommand(value, shell) - if (!tokenized.ok) { + const bypass = tokenizeStartupCommand(bypassArg, shell) + if (!tokenized.ok || !bypass.ok || bypass.tokens.length === 0) { return false } const terminator = tokenized.tokens.indexOf('--') const options = terminator === -1 ? tokenized.tokens : tokenized.tokens.slice(0, terminator) - return options.includes(bypassArg) + // A token sequence: several agents' bypass is a flag and its value (`--permission-mode X`). + return options.some((_, start) => + bypass.tokens.every((token, offset) => options[start + offset] === token) + ) } function sanitizeTuiAgentLaunchArgs(agent: TuiAgent, args: string): string { From a3f2809ab258562ab17474030ce34bc6f63b542d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:43:42 -0700 Subject: [PATCH 106/269] fix(provider-process): an agent that closes its stdout reads as a broken connection The macOS/Linux supervisor copied the agent's stdout with pipe(), which never ends process.stdout. An agent that closed its stdout but kept running left Orca waiting on a stream that would never speak again: the Grok turn stayed Working and nothing was stopped. The supervisor now ends its own stdout when the agent's ends, so the existing connection-loss path runs: running send unknown, turn unverifiable, child stopped, ended once its exit is proven. --- .../acp/acp-structured-broken-stream.test.ts | 101 ++++++++++++++++++ ...der-process-supervisor.integration.test.ts | 21 ++++ .../provider-process-supervisor.ts | 3 + 3 files changed, 125 insertions(+) create mode 100644 src/main/acp/acp-structured-broken-stream.test.ts diff --git a/src/main/acp/acp-structured-broken-stream.test.ts b/src/main/acp/acp-structured-broken-stream.test.ts new file mode 100644 index 000000000000..7ccc06a76efd --- /dev/null +++ b/src/main/acp/acp-structured-broken-stream.test.ts @@ -0,0 +1,101 @@ +// A real child (plain Node, never an agent CLI) that stops talking mid-turn by closing its stdout +// while the process itself keeps running: the connection broke, nothing exited. + +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + GROK_CONFIG_OPTIONS, + openAcpAdapterRig, + PROVIDER_SESSION, + sendHello +} from './acp-structured-adapter.test-support' +import { spawnAcpStructuredChild } from './acp-structured-child' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +// Answers the handshake, echoes one reply chunk for the prompt, then closes fd 1 and stays alive. +const CLOSES_STDOUT_MID_TURN = String.raw` + const fs = require('node:fs') + fs.writeFileSync(process.env.ORCA_TEST_PID_FILE, String(process.pid)) + const send = (frame) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...frame }) + '\n') + let buffered = '' + process.stdin.setEncoding('utf8').on('data', (chunk) => { + buffered += chunk + let newline + while ((newline = buffered.indexOf('\n')) !== -1) { + const frame = JSON.parse(buffered.slice(0, newline)) + buffered = buffered.slice(newline + 1) + if (frame.method === 'initialize') { + send({ id: frame.id, result: { protocolVersion: 1, agentCapabilities: {} } }) + } else if (frame.method === 'session/new') { + send({ id: frame.id, result: JSON.parse(process.env.ORCA_TEST_SESSION) }) + } else if (frame.method === 'session/prompt') { + const update = { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'partial' } } + send({ + method: 'session/update', + params: { sessionId: frame.params.sessionId, update, _meta: frame.params._meta } + }) + process.stdout.write('', () => fs.closeSync(1)) + } + } + }) + setInterval(() => {}, 60000) +` + +function alive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +describe('ACP agent that closes its stdout but keeps running', () => { + it('reads the closed stream as a broken connection: turn unverifiable, child stopped, ended once', async () => { + const pidFile = join(mkdtempSync(join(tmpdir(), 'orca-acp-broken-stream-')), 'pid') + const rig = await openAcpAdapterRig({ + deps: { + spawnChild: () => + spawnAcpStructuredChild({ + command: process.execPath, + args: ['-e', CLOSES_STDOUT_MID_TURN], + cwd: process.cwd(), + env: { + ORCA_TEST_PID_FILE: pidFile, + ORCA_TEST_SESSION: JSON.stringify({ + sessionId: PROVIDER_SESSION, + configOptions: GROK_CONFIG_OPTIONS + }) + } + }) + } + }) + onTestFinished(async () => { + await rig.adapter.closeAll().catch(() => {}) + }) + await rig.acquire() + await sendHello(rig, 'broken') + const turns = async () => + (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + await vi.waitFor( + async () => expect((await turns()).at(-1)).toMatchObject({ state: 'unverifiable' }), + { timeout: 10_000, interval: 20 } + ) + await vi.waitFor( + () => + expect(rig.lifecycle).toMatchObject([ + { type: 'ended', cause: 'unexpected-exit', acquisitionGeneration: 'gen-acp' } + ]), + { timeout: 15_000, interval: 20 } + ) + expect(alive(Number(readFileSync(pidFile, 'utf8')))).toBe(false) + expect((await turns()).some((turn) => turn.state === 'completed')).toBe(false) + }, 30_000) +}) diff --git a/src/main/provider-process/provider-process-supervisor.integration.test.ts b/src/main/provider-process/provider-process-supervisor.integration.test.ts index b3b7f068b7d3..034d72949805 100644 --- a/src/main/provider-process/provider-process-supervisor.integration.test.ts +++ b/src/main/provider-process/provider-process-supervisor.integration.test.ts @@ -281,6 +281,27 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe expect(alive(pids.grandchild)).toBe(false) }) + it('ends its own stdout when the provider closes stdout but keeps running', async () => { + const { supervisor } = launchSupervisor( + {}, + {}, + { + command: process.execPath, + args: [ + '-e', + "process.stdout.write(JSON.stringify({ provider: process.pid }) + '\\n', () => require('node:fs').closeSync(1)); setInterval(() => {}, 60000)" + ] + } + ) + const ended = new Promise((resolve) => supervisor.stdout!.once('end', resolve)) + const { provider } = await readPids(supervisor, ['provider']) + supervisor.stdout!.resume() + + await ended + expect(alive(provider)).toBe(true) + expect(alive(supervisor.pid!)).toBe(true) + }) + it('closes a provider that exits on stdin end without waiting out any grace', async () => { const { supervisor, exit } = launchSupervisor( {}, diff --git a/src/main/provider-process/provider-process-supervisor.ts b/src/main/provider-process/provider-process-supervisor.ts index a446167b2f05..3238418795bc 100644 --- a/src/main/provider-process/provider-process-supervisor.ts +++ b/src/main/provider-process/provider-process-supervisor.ts @@ -96,6 +96,9 @@ process.stdin.once('end', scheduleOwnerShutdown) process.stdin.once('close', scheduleOwnerShutdown) process.stdin.pipe(child.stdin) child.stdout.pipe(process.stdout) +// pipe() never ends process.stdout: forward the provider's closed stdout so the owner reads EOF. +child.stdout.once('end', () => process.stdout.end()) +child.stdout.once('error', () => process.stdout.end()) child.stderr.pipe(process.stderr) // A dead owner's stdout pipe raises EPIPE; unhandled, it would end this pid before the group. for (const stream of [process.stdin, process.stdout, process.stderr, child.stdin, child.stdout, child.stderr]) { From 8973413685fce159fd1513360dcf41c7ed42604f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:00:50 -0700 Subject: [PATCH 107/269] refactor(native-chat): the refusal reason words table is its own module No behavior change. The notice module was at its line limit; the per-reason words table and its lookup move beside it so the next reason can be added. --- .../agent-session-refusal-notice.test.ts | 2 +- src/shared/agent-session-refusal-notice.ts | 185 +----------------- .../agent-session-refusal-reason-words.ts | 185 ++++++++++++++++++ 3 files changed, 188 insertions(+), 184 deletions(-) create mode 100644 src/shared/agent-session-refusal-reason-words.ts diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index b60356965c0b..d2feb6f97052 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -7,11 +7,11 @@ import { agentSessionReadHistoryRefusalParts, agentSessionRefusalCauseParts, agentSessionRefusalNotice, - agentSessionRefusalReasonWords, agentSessionWriteFailureNotice, agentSessionWriteNoticeEnglish, agentSessionWriteNoticeParts } from './agent-session-refusal-notice' +import { agentSessionRefusalReasonWords } from './agent-session-refusal-reason-words' import { AGENT_SESSION_WRITE_NOTICE_COPY, type AgentSessionWriteNoticeSentence diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 93a1409de030..31c576dadd6c 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -10,22 +10,18 @@ // something beside the words retries it. // Surfaces keep the fact and choose the words when they show it, so nothing saved carries copy. -import type { AgentSessionFailureKind } from './agent-session-failure' import { agentSessionFailureSentence, type AgentSessionFailureWordsContext } from './agent-session-failure-words' -import type { AgentSessionRefusalReason } from './agent-session-refusal-details' +import { agentSessionRefusalReasonWords } from './agent-session-refusal-reason-words' import { AGENT_SESSION_HISTORY_UNREAD_CAUSES, AGENT_SESSION_WRITE_NOTICE_COPY, type AgentSessionWriteNoticePart, type AgentSessionWriteNoticeSentence } from './agent-session-write-notice-copy' -import type { - AgentSessionWireRefusal, - AgentSessionWireRefusalCode -} from './agent-session-wire-refusals' +import type { AgentSessionWireRefusal } from './agent-session-wire-refusals' import { agentSessionRefusalFailure, parseAgentSessionWriteFailure, @@ -56,183 +52,6 @@ export function agentSessionWriteNotDoneParts( return write === 'composer-send' ? ['notDoneSend', 'tryAgainComposerSend'] : [NOT_DONE[write]] } -/** What the person can do about a refusal with this reason. */ -export type AgentSessionRefusalAction = - /** Use the control that sent the write again. */ - | 'retry' - /** Wait for what the notice names; its step says what. */ - | 'wait' - /** Take the step the notice names first. */ - | 'actFirst' - /** Continue somewhere else: the current conversation, or a new chat. */ - | 'goElsewhere' - | 'checkChat' - | 'updateOrca' - /** What the write was for is over: the question it answered moved on. */ - | 'nothingLeft' - /** Orca's own state or fault; nothing the person does gets past it. */ - | 'hostFinding' - -export type AgentSessionRefusalReasonWords = - /** The code's own words are the honest ones for this reason. */ - | { words: 'code'; action: AgentSessionRefusalAction } - /** What stopped the write, and the step past it where the person has one to take. */ - | { - cause: AgentSessionWriteNoticeSentence - step?: AgentSessionWriteNoticeSentence - action: AgentSessionRefusalAction - } - /** A start that failed: the sentence that failure has everywhere, whose next step is a send. */ - | { fact: AgentSessionFailureKind; action: AgentSessionRefusalAction } - -function codeWords(action: AgentSessionRefusalAction): AgentSessionRefusalReasonWords { - return { words: 'code', action } -} - -function causeWords( - cause: AgentSessionWriteNoticeSentence, - action: AgentSessionRefusalAction, - step?: AgentSessionWriteNoticeSentence -): AgentSessionRefusalReasonWords { - return step ? { cause, step, action } : { cause, action } -} - -const AGENT_STARTING = causeWords('agentStarting', 'wait', 'waitForStart') -const OWNER_UNPROVEN = causeWords('ownerUnproven', 'actFirst', 'reopenChat') -// Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. -const TERMINAL_CLAIM = causeWords('terminalAgentHoldsChat', 'actFirst', 'quitTerminalAgent') - -// Every reason of every code, so a reason the host adds does not compile until it has words. -// Reasons only a create, attach, hold or adopted import meets never reach a chat write; they keep -// their code's words. -const REASON_WORDS = { - agent_session_operation_invalid: { - requestMalformed: codeWords('hostFinding'), - operationIdInvalid: codeWords('hostFinding'), - messageIdReused: codeWords('hostFinding'), - // Settled under that id, so the control's retry goes out under a new one. - operationRefusedEarlier: codeWords('retry'), - journalWriteFailed: causeWords('recordFailed', 'retry'), - conversationCleared: causeWords( - 'conversationCleared', - 'goElsewhere', - 'openCurrentConversation' - ), - // Only an older host sends this, and it keeps refusing the chat, so only a new chat continues. - clearUnconfirmed: causeWords('clearUnfinished', 'goElsewhere', 'startNewChat'), - // Only an older host sends this, for a /clear it never settled; only that host resolves it. - conversationCommandUnconfirmed: codeWords('hostFinding'), - conversationCommandInFlight: causeWords('commandRunning', 'wait', 'waitForCommand'), - // The chat's agent process is being replaced, which a start or restart does. - handoffInFlight: AGENT_STARTING, - turnActive: causeWords('turnActive', 'wait', 'waitForTurn'), - promptPending: causeWords('promptPending', 'actFirst', 'answerFirst'), - backgroundTasksRunning: causeWords('backgroundTasksRunning', 'wait', 'waitForBackgroundTasks'), - messagesUnsettled: causeWords('messagesUnsettled', 'actFirst', 'settleEarlierMessage'), - // No chat surface sends a rewind; a replayed one says only that it did not happen. - rewindRefused: codeWords('hostFinding'), - rewindUnconfirmed: codeWords('hostFinding'), - promptGone: causeWords('questionChanged', 'nothingLeft'), - optionRejected: causeWords('optionRejected', 'retry'), - providerStarting: AGENT_STARTING, - goalsUnsupported: causeWords('goalsUnsupported', 'hostFinding'), - providerRejected: causeWords('agentRefused', 'retry'), - providerStartFailed: { fact: 'providerStartFailed', action: 'retry' }, - notSignedIn: { fact: 'notSignedIn', action: 'actFirst' }, - historyTooLarge: { fact: 'historyTooLarge', action: 'goElsewhere' }, - managedAccountEnvOverride: { fact: 'managedAccountEnvOverride', action: 'actFirst' }, - accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' }, - managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, - attachFailed: codeWords('retry') - }, - agent_session_ownership_unknown: { - sessionNotAttached: codeWords('retry'), - noLiveOwner: codeWords('retry'), - ownerUnproven: OWNER_UNPROVEN, - claimConflicted: TERMINAL_CLAIM, - recordMissing: codeWords('retry'), - replaySuperseded: codeWords('retry'), - leaseMoved: codeWords('retry'), - spawnIdentityMismatch: codeWords('hostFinding'), - notResumable: codeWords('retry'), - noProviderChild: codeWords('retry'), - conversationHeldElsewhere: codeWords('retry'), - // Trying again retries the stop that could not prove the exit. - previousExitUnverifiable: causeWords('ownerUnproven', 'retry') - }, - agent_session_conflict: { - chatStarting: AGENT_STARTING, - ownerUnproven: OWNER_UNPROVEN, - claimConflicted: TERMINAL_CLAIM, - ownerAlive: codeWords('retry'), - identityMismatch: codeWords('hostFinding'), - sessionExists: codeWords('retry'), - conversationHeldElsewhere: codeWords('retry'), - tabIdTaken: codeWords('retry') - }, - execution_owner_reconciling: { - hostReconciling: causeWords('hostReconciling', 'wait', 'waitMoment'), - recordUnreadable: causeWords('recordUnreadable', 'hostFinding') - }, - agent_session_checkpoint_stale: { - fenceStale: codeWords('retry'), - leaseMoved: codeWords('retry'), - recordMissing: codeWords('retry') - }, - agent_session_identity_required: { - recordMissing: causeWords('chatNotFound', 'goElsewhere', 'startNewChat'), - transcriptNotFound: codeWords('retry'), - transcriptUnreadable: codeWords('hostFinding') - }, - agent_session_operation_conflict: { - fingerprintMismatch: codeWords('hostFinding'), - operationIdReused: codeWords('hostFinding'), - handoffInFlight: codeWords('retry') - }, - agent_session_operation_expired: { operationExpired: codeWords('retry') }, - agent_session_operation_capacity: { operationCapacity: codeWords('wait') }, - agent_session_operation_unknown: { - outcomeUnknown: codeWords('checkChat'), - resultLost: codeWords('checkChat'), - rewindUnconfirmed: codeWords('checkChat'), - tabUnconfirmed: codeWords('checkChat') - }, - agent_session_item_revision_stale: { promptMoved: codeWords('nothingLeft') }, - agent_session_already_resolved: { promptAlreadyResolved: codeWords('nothingLeft') }, - agent_session_journal_unreadable: { - // No retry reads past damage, and the words name no step: it only can't load. - journalCorrupt: causeWords('historyUnusable', 'hostFinding'), - // Says its step despite 'retry' unless a Retry stands beside it: the phone often has none. - journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain'), - journalWrittenByNewerOrca: causeWords('savedByNewerOrca', 'updateOrca', 'updateOrcaToKeepUsing') - }, - // Thrown, so a client meets these only as an RPC error. The code's own words ask for an update, - // which only a method the host doesn't know proves; no reason here means an older Orca. An - // unsupported location or agent, or no chat host, is not fixed by updating, and a client missing - // the capability words this with its own older copy. - structured_agent_session_unsupported: { - clientCapabilityMissing: causeWords('notAvailable', 'hostFinding'), - hostDisabled: causeWords('notAvailable', 'hostFinding'), - hostUnsupported: causeWords('notAvailable', 'hostFinding') - }, - agent_session_owner_restart_failed: {} -} satisfies { - [C in AgentSessionWireRefusalCode]: Record< - AgentSessionRefusalReason, - AgentSessionRefusalReasonWords - > -} - -/** The words and next step a reason gets; undefined when the refusal names none. */ -export function agentSessionRefusalReasonWords( - failure: AgentSessionWriteRefusal -): AgentSessionRefusalReasonWords | undefined { - const reason = failure.details?.reason - const byReason: Partial> | undefined = - REASON_WORDS[failure.code] - return reason === undefined ? undefined : byReason?.[reason] -} - /** A cause, and that the request did not happen unless the cause already says so. */ function causeParts( cause: AgentSessionWriteNoticeSentence, diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts new file mode 100644 index 000000000000..715cbd1531cb --- /dev/null +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -0,0 +1,185 @@ +// What each refusal reason means for the person: the words it gets and what they can do about it. + +import type { AgentSessionFailureKind } from './agent-session-failure' +import type { AgentSessionRefusalReason } from './agent-session-refusal-details' +import type { AgentSessionWriteNoticeSentence } from './agent-session-write-notice-copy' +import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals' +import type { AgentSessionWriteRefusal } from './agent-session-write-failure' + +/** What the person can do about a refusal with this reason. */ +export type AgentSessionRefusalAction = + /** Use the control that sent the write again. */ + | 'retry' + /** Wait for what the notice names; its step says what. */ + | 'wait' + /** Take the step the notice names first. */ + | 'actFirst' + /** Continue somewhere else: the current conversation, or a new chat. */ + | 'goElsewhere' + | 'checkChat' + | 'updateOrca' + /** What the write was for is over: the question it answered moved on. */ + | 'nothingLeft' + /** Orca's own state or fault; nothing the person does gets past it. */ + | 'hostFinding' + +export type AgentSessionRefusalReasonWords = + /** The code's own words are the honest ones for this reason. */ + | { words: 'code'; action: AgentSessionRefusalAction } + /** What stopped the write, and the step past it where the person has one to take. */ + | { + cause: AgentSessionWriteNoticeSentence + step?: AgentSessionWriteNoticeSentence + action: AgentSessionRefusalAction + } + /** A start that failed: the sentence that failure has everywhere, whose next step is a send. */ + | { fact: AgentSessionFailureKind; action: AgentSessionRefusalAction } + +function codeWords(action: AgentSessionRefusalAction): AgentSessionRefusalReasonWords { + return { words: 'code', action } +} + +function causeWords( + cause: AgentSessionWriteNoticeSentence, + action: AgentSessionRefusalAction, + step?: AgentSessionWriteNoticeSentence +): AgentSessionRefusalReasonWords { + return step ? { cause, step, action } : { cause, action } +} + +const AGENT_STARTING = causeWords('agentStarting', 'wait', 'waitForStart') +const OWNER_UNPROVEN = causeWords('ownerUnproven', 'actFirst', 'reopenChat') +// Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. +const TERMINAL_CLAIM = causeWords('terminalAgentHoldsChat', 'actFirst', 'quitTerminalAgent') + +// Every reason of every code, so a reason the host adds does not compile until it has words. +// Reasons only a create, attach, hold or adopted import meets never reach a chat write; they keep +// their code's words. +const REASON_WORDS = { + agent_session_operation_invalid: { + requestMalformed: codeWords('hostFinding'), + operationIdInvalid: codeWords('hostFinding'), + messageIdReused: codeWords('hostFinding'), + // Settled under that id, so the control's retry goes out under a new one. + operationRefusedEarlier: codeWords('retry'), + journalWriteFailed: causeWords('recordFailed', 'retry'), + conversationCleared: causeWords( + 'conversationCleared', + 'goElsewhere', + 'openCurrentConversation' + ), + // Only an older host sends this, and it keeps refusing the chat, so only a new chat continues. + clearUnconfirmed: causeWords('clearUnfinished', 'goElsewhere', 'startNewChat'), + // Only an older host sends this, for a /clear it never settled; only that host resolves it. + conversationCommandUnconfirmed: codeWords('hostFinding'), + conversationCommandInFlight: causeWords('commandRunning', 'wait', 'waitForCommand'), + // The chat's agent process is being replaced, which a start or restart does. + handoffInFlight: AGENT_STARTING, + turnActive: causeWords('turnActive', 'wait', 'waitForTurn'), + promptPending: causeWords('promptPending', 'actFirst', 'answerFirst'), + backgroundTasksRunning: causeWords('backgroundTasksRunning', 'wait', 'waitForBackgroundTasks'), + messagesUnsettled: causeWords('messagesUnsettled', 'actFirst', 'settleEarlierMessage'), + // No chat surface sends a rewind; a replayed one says only that it did not happen. + rewindRefused: codeWords('hostFinding'), + rewindUnconfirmed: codeWords('hostFinding'), + promptGone: causeWords('questionChanged', 'nothingLeft'), + optionRejected: causeWords('optionRejected', 'retry'), + providerStarting: AGENT_STARTING, + goalsUnsupported: causeWords('goalsUnsupported', 'hostFinding'), + providerRejected: causeWords('agentRefused', 'retry'), + providerStartFailed: { fact: 'providerStartFailed', action: 'retry' }, + notSignedIn: { fact: 'notSignedIn', action: 'actFirst' }, + historyTooLarge: { fact: 'historyTooLarge', action: 'goElsewhere' }, + managedAccountEnvOverride: { fact: 'managedAccountEnvOverride', action: 'actFirst' }, + accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' }, + managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, + attachFailed: codeWords('retry'), + hostStopped: { fact: 'hostStopped', action: 'retry' } + }, + agent_session_ownership_unknown: { + sessionNotAttached: codeWords('retry'), + noLiveOwner: codeWords('retry'), + ownerUnproven: OWNER_UNPROVEN, + claimConflicted: TERMINAL_CLAIM, + recordMissing: codeWords('retry'), + replaySuperseded: codeWords('retry'), + leaseMoved: codeWords('retry'), + spawnIdentityMismatch: codeWords('hostFinding'), + notResumable: codeWords('retry'), + noProviderChild: codeWords('retry'), + conversationHeldElsewhere: codeWords('retry'), + // Trying again retries the stop that could not prove the exit. + previousExitUnverifiable: causeWords('ownerUnproven', 'retry') + }, + agent_session_conflict: { + chatStarting: AGENT_STARTING, + ownerUnproven: OWNER_UNPROVEN, + claimConflicted: TERMINAL_CLAIM, + ownerAlive: codeWords('retry'), + identityMismatch: codeWords('hostFinding'), + sessionExists: codeWords('retry'), + conversationHeldElsewhere: codeWords('retry'), + tabIdTaken: codeWords('retry') + }, + execution_owner_reconciling: { + hostReconciling: causeWords('hostReconciling', 'wait', 'waitMoment'), + recordUnreadable: causeWords('recordUnreadable', 'hostFinding') + }, + agent_session_checkpoint_stale: { + fenceStale: codeWords('retry'), + leaseMoved: codeWords('retry'), + recordMissing: codeWords('retry') + }, + agent_session_identity_required: { + recordMissing: causeWords('chatNotFound', 'goElsewhere', 'startNewChat'), + transcriptNotFound: codeWords('retry'), + transcriptUnreadable: codeWords('hostFinding') + }, + agent_session_operation_conflict: { + fingerprintMismatch: codeWords('hostFinding'), + operationIdReused: codeWords('hostFinding'), + handoffInFlight: codeWords('retry') + }, + agent_session_operation_expired: { operationExpired: codeWords('retry') }, + agent_session_operation_capacity: { operationCapacity: codeWords('wait') }, + agent_session_operation_unknown: { + outcomeUnknown: codeWords('checkChat'), + resultLost: codeWords('checkChat'), + rewindUnconfirmed: codeWords('checkChat'), + tabUnconfirmed: codeWords('checkChat') + }, + agent_session_item_revision_stale: { promptMoved: codeWords('nothingLeft') }, + agent_session_already_resolved: { promptAlreadyResolved: codeWords('nothingLeft') }, + agent_session_journal_unreadable: { + // No retry reads past damage, and the words name no step: it only can't load. + journalCorrupt: causeWords('historyUnusable', 'hostFinding'), + // Says its step despite 'retry' unless a Retry stands beside it: the phone often has none. + journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain'), + journalWrittenByNewerOrca: causeWords('savedByNewerOrca', 'updateOrca', 'updateOrcaToKeepUsing') + }, + // Thrown, so a client meets these only as an RPC error. The code's own words ask for an update, + // which only a method the host doesn't know proves; no reason here means an older Orca. An + // unsupported location or agent, or no chat host, is not fixed by updating, and a client missing + // the capability words this with its own older copy. + structured_agent_session_unsupported: { + clientCapabilityMissing: causeWords('notAvailable', 'hostFinding'), + hostDisabled: causeWords('notAvailable', 'hostFinding'), + hostUnsupported: causeWords('notAvailable', 'hostFinding') + }, + agent_session_owner_restart_failed: {} +} satisfies { + [C in AgentSessionWireRefusalCode]: Record< + AgentSessionRefusalReason, + AgentSessionRefusalReasonWords + > +} + +/** The words and next step a reason gets; undefined when the refusal names none. */ +export function agentSessionRefusalReasonWords( + failure: AgentSessionWriteRefusal +): AgentSessionRefusalReasonWords | undefined { + const reason = failure.details?.reason + const byReason: Partial> | undefined = + REASON_WORDS[failure.code] + return reason === undefined ? undefined : byReason?.[reason] +} From f1aca12fc97e278ae8aae163ee1d02102490aa79 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:00:50 -0700 Subject: [PATCH 108/269] fix(native-chat): a start that never answers says so beside Retry A Grok start that did not answer within its bound failed as a bare "Chat could not be started." It is now refused with a new typed start reason, hostStopped, which reads with the existing sentence the host already uses for a start it stopped: "Grok never finished starting, so Orca stopped it." An older client reads the unknown reason as none and keeps today's words. --- src/main/acp/acp-structured-acquire.ts | 3 +++ .../acp/acp-structured-host-lifecycle.test.ts | 17 +++++++++++++++++ ...structured-session-adapter-lifecycle.test.ts | 7 +++++-- .../structured-agent-session-failure-text.ts | 3 ++- ...tStructuredSession.launch-lifecycle.test.tsx | 17 +++++++++++++++++ src/shared/agent-session-refusal-details.ts | 4 +++- 6 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 2da61b88071c..b67da9caa56e 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -288,6 +288,9 @@ export async function acquireAcpStructuredSession(input: { 'notSignedIn' ) } + if (error instanceof AcpStartupTimeoutError) { + throw new AgentSessionAcquisitionRefusal(error.message, 'hostStopped') + } throw error } finally { clearTimeout(startupDeadline) diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index ac02fa2d2cee..4dafb16f1cb7 100644 --- a/src/main/acp/acp-structured-host-lifecycle.test.ts +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -211,6 +211,23 @@ describe('closing or stopping a Grok chat while it starts', () => { expect(await rig.adapter.closeSession(SESSION)).toBe(true) }) + it('refuses a new chat whose start never answered with why, worded like a host-stopped start', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}), + deps: { startupTimeoutMs: 30 } + }) + const attached = await host.attach(CALLER, attachParams()) + expect(attached).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'hostStopped' }, + message: expect.stringContaining('Grok never finished starting, so Orca stopped it.') + } + }) + expect(rig.child().exited).toBe(true) + }) + it('lets a Stop reach a child Grok never finished initializing', async () => { const { rig, host } = await openHostRig({ script: (agent) => agent.on('initialize', () => {}), diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index d864383cd44e..f97f19e3fe7e 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -6,7 +6,6 @@ import { SESSION } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { StructuredAgentSessionTaskQueue } from '../native-chat/agent-session-wire/structured-agent-session-task-queue' -import { AcpStartupTimeoutError } from './acp-structured-acquire' import { tick } from './acp-scripted-agent.test-support' import { GROK, @@ -270,7 +269,11 @@ describe('ACP startup that never answers', () => { deps: { startupTimeoutMs: 20 } }) const failure = await rig.acquire().catch((error: unknown) => error) - expect(failure).toBeInstanceOf(AcpStartupTimeoutError) + expect(failure).toMatchObject({ + name: 'AgentSessionAcquisitionRefusal', + reason: 'hostStopped', + message: 'Grok did not finish starting within 0 seconds' + }) expect(rig.child().closes).toBe(1) await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index c0c2172230dc..64eb196ed536 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -26,7 +26,8 @@ const TYPED_START_REFUSALS = [ 'historyTooLarge', 'managedAccountEnvOverride', 'accountSwitchInProgress', - 'managedAccountUnsupported' + 'managedAccountUnsupported', + 'hostStopped' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx index 644c1c657500..6bde4ea8da2d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx @@ -116,6 +116,23 @@ describe('NativeChatStructuredSession launch lifecycle', () => { expect(screen.queryByText(/agent_session_/)).toBeNull() }) + it('says the agent never finished starting when Orca stopped a start that never answered', () => { + mocks.launchLifecycle = 'failed' + mocks.launchFailure = { + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'hostStopped' } + } + render(sessionView()) + + expect( + screen.getByText( + 'Chat could not be started. Codex never finished starting, so Orca stopped it.' + ) + ).toBeTruthy() + expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy() + }) + it("keeps a step the Retry doesn't take, and drops one it does", () => { mocks.launchLifecycle = 'failed' mocks.launchFailure = { diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index b56ca5fbeaaa..2ca9bd1960d1 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -52,7 +52,9 @@ export const AGENT_SESSION_REFUSAL_REASONS = { /** A Claude account is added in WSL and no Windows one is selected, which a chat can't run under. */ 'managedAccountUnsupported', /** The agent started, then Orca could not open the chat's conversation for it. */ - 'attachFailed' + 'attachFailed', + /** The agent never finished starting, so Orca stopped it. */ + 'hostStopped' ], agent_session_ownership_unknown: [ 'sessionNotAttached', From 8a890209c4ac5eaa2f55cfff9c1aff6456571204 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:00:50 -0700 Subject: [PATCH 109/269] fix(native-chat): a message sent while a new chat starts can be stopped Until the host publishes a new chat to the view, nothing sent has reached it and the session has no fence, so Stop was never offered: a start that never answered held the message with no way to take it back. Stop now shows while such a message waits and returns it to the composer, the same local step Stop already takes for unsent text; nothing is asked of the host. --- ...uctured-agent-session-provisional.test.tsx | 48 +++++++++++++++++-- .../use-structured-agent-session.ts | 10 ++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx index fd4445b68b60..19b94e1dad29 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx @@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import type { StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' const mocks = vi.hoisted(() => ({ @@ -10,7 +11,9 @@ const mocks = vi.hoisted(() => ({ read: vi.fn<(args: { isVisible?: boolean }) => void>(), outbox: vi.fn<(args: { fence: number | null; submissions: readonly unknown[] }) => void>(), send: vi.fn<(text: string) => boolean>(), - retry: vi.fn<(clientMessageId: string) => void>() + retry: vi.fn<(clientMessageId: string) => void>(), + withdrawUnsent: vi.fn<() => void>(), + outboxEntries: [] as StructuredAgentSessionOutboxEntry[] })) let readState: StructuredAgentSessionState @@ -43,10 +46,11 @@ vi.mock('./use-structured-agent-session-outbox', () => ({ }) => { mocks.outbox(args) return { - outbox: [], + outbox: mocks.outboxEntries, error: null, send: mocks.send, - retry: mocks.retry + retry: mocks.retry, + withdrawUnsent: mocks.withdrawUnsent } } })) @@ -99,6 +103,7 @@ function sessionState(): StructuredAgentSessionState { describe('useStructuredAgentSession provisional launch gate', () => { beforeEach(() => { vi.clearAllMocks() + mocks.outboxEntries = [] readState = sessionState() mocks.send.mockReturnValue(true) mocks.call.mockResolvedValue(OPTIONS) @@ -145,6 +150,43 @@ describe('useStructuredAgentSession provisional launch gate', () => { expect(methodsCalled()).toEqual(['agentSession.modelCatalog']) }) + it('offers Stop for a message sent while the launch is unpublished, and takes it back locally', async () => { + const render = () => + renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'grok', + isVisible: true, + transportEnabled: false, + launch: { kind: 'new', heldOptions: {} } + }) + ) + expect(render().result.current.canStop).toBe(false) + mocks.outboxEntries = [ + { + clientMessageId: 'sent-while-starting', + sessionId: 'session-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + previewUris: [], + state: 'queued', + queuedAt: 1, + lastAttemptAt: null, + retryAfterUnknownSubmittedAt: null + } + ] + const { result } = render() + expect(result.current.canStop).toBe(true) + + await act(async () => { + await result.current.stop() + }) + + expect(mocks.withdrawUnsent).toHaveBeenCalledTimes(1) + // Nothing reached a host, so nothing is asked of one. + expect(methodsCalled().filter((method) => method === 'agentSession.cancel')).toEqual([]) + }) + it('shows no stored selection for a chat this view did not launch', () => { const { result } = renderHook(() => useStructuredAgentSession({ diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 157893a8287d..cbec4f722085 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -148,8 +148,14 @@ export function useStructuredAgentSession(args: { // Stop before a turn opens needs that form. An older host can stop only a turn it has opened. const stopsConversation = useStructuredAgentSessionHostStopsConversation(target) && transportState.fence !== null + // Before the host publishes this chat to this view, nothing sent has reached it: a Stop takes back + // what this client holds, so a start that never answers cannot hold the message hostage. + const stopsUnpublishedSend = + !transportEnabled && + hasUnsentStructuredAgentSessionOutboxEntry(outbox, transportState.submissions) const canStop = transportState.turnId !== null || + stopsUnpublishedSend || (stopsConversation && (transportState.isWorking || hasUnsentStructuredAgentSessionOutboxEntry(outbox, transportState.submissions))) @@ -230,6 +236,10 @@ export function useStructuredAgentSession(args: { turnId: transportState.turnId, canStop, stop: () => { + if (!transportEnabled) { + outboxController.withdrawUnsent() + return Promise.resolve(null) + } if (stopsConversation) { // Unsent text this client still owns goes back to its composer — a local move. // Host-held drafts are never withdrawn by a Stop: the host pauses them and From ebb07e6461a8eb0d23a54811ccaa571a7b34253d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:05:07 -0700 Subject: [PATCH 110/269] Keep the journal store under its line limit after the main merge --- .../native-chat/agent-session-journal/journal-store.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index c101c21fd556..19a8b61a62cd 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -69,7 +69,7 @@ import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' import { createJournalStoreCollaborators } from './journal-store-collaborators' import { journalStoreLoadedFields } from './journal-store-open' -import type { JournalItemAppender, JournalResolvedItem } from './journal-item-appender' +import type { JournalItemAppender } from './journal-item-appender' import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' import type { JournalStopMarks } from './journal-stop-marks' @@ -306,12 +306,8 @@ export class AgentSessionJournal { } /** An upsert whose row is chosen from the fold at its own turn in the queue; null writes nothing. */ - appendResolvedItem( - resolve: () => JournalResolvedItem | null, - options: JournalItemAppendOptions - ): Promise { - return this.itemAppender.appendResolved(resolve, options) - } + appendResolvedItem: JournalItemAppender['appendResolved'] = (resolve, options) => + this.itemAppender.appendResolved(resolve, options) appendTombstone( identity: AgentJournalItemIdentity, From 3717fc48b382d87728a8866f4f1fd1295355fcc9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:05:33 -0700 Subject: [PATCH 111/269] test(native-chat): use opaque handle in queued rejection fixture --- .../journal-queued-rejection-batch.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts index d218a356fae5..cd14ce2fe943 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentSessionJournalIdentity @@ -23,7 +24,7 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } + providerHandle: claudeProviderHandle('native-1', null) } const START_FAILED = agentSessionFailureWords(agentSessionFailureFact('providerStartFailed'), { From 01439e8aaeb8c4eb72bc920b6947e44ea10cfd37 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:49:38 -0700 Subject: [PATCH 112/269] refactor(native-chat): the admit-now check is its own module after the resend-answer merge Main's recorded-answer path for a resent message pushed the admission module past its line limit. --- ...structured-agent-session-host-mutations.ts | 2 +- ...ctured-agent-session-mutation-admission.ts | 32 ---------------- ...tured-agent-session-mutation-admits-now.ts | 38 +++++++++++++++++++ 3 files changed, 39 insertions(+), 33 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admits-now.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index e43fac853c49..48372d3b8433 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -37,7 +37,7 @@ import { setOptionPlan, type MutationPlan } from './structured-agent-session-mutation-plans' -import { agentSessionMutationAdmitsNow } from './structured-agent-session-mutation-admission' +import { agentSessionMutationAdmitsNow } from './structured-agent-session-mutation-admits-now' import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import { mutateWithChatStop } from './structured-agent-session-chat-stop' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 4a5c04dd9d34..3ed04e1f2fec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -214,38 +214,6 @@ export async function admitAndRunAgentSessionMutation( : refuseAgentSessionMutation(outcome.refusal) } -/** Whether admission would run `plan` for the first time now, placing nothing: for an effect that - * must not wait in the session's queue for its turn. */ -export function agentSessionMutationAdmitsNow( - request: Pick, 'store' | 'callerKey' | 'envelope' | 'now'> & { - plan: Pick, 'method' | 'fields' | 'operationIdScope' | 'conversationWrite'> - } -): boolean { - const { plan, envelope } = request - const hostFingerprint = computeAgentSessionPayloadFingerprint({ - method: plan.method, - sessionId: envelope.sessionId, - fields: plan.fields - }) - const evaluated = request.store.evaluateMutationOperation({ - callerKey: request.callerKey, - envelope, - hostFingerprint, - now: request.now(), - ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}) - }) - return ( - evaluated !== null && - admitAgentSessionMutation({ - envelope, - hostFingerprint, - ledger: evaluated.decision, - lease: evaluated.record.lease, - ...(plan.conversationWrite ? { conversationWrite: true } : {}) - }).decision === 'admit' - ) -} - /** * A resend of a recorded id, answered with no admit write: a refusal its first run recorded, with * nothing opened; otherwise, after the plan's own preparation for a replay, from the conversation diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admits-now.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admits-now.ts new file mode 100644 index 000000000000..cc1e2bd5076c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admits-now.ts @@ -0,0 +1,38 @@ +import { + admitAgentSessionMutation, + computeAgentSessionPayloadFingerprint +} from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionMutationRequest } from './structured-agent-session-mutation-admission' +import type { MutationPlan } from './structured-agent-session-mutation-plans' + +/** Whether admission would run `plan` for the first time now, placing nothing: for an effect that + * must not wait in the session's queue for its turn. */ +export function agentSessionMutationAdmitsNow( + request: Pick, 'store' | 'callerKey' | 'envelope' | 'now'> & { + plan: Pick, 'method' | 'fields' | 'operationIdScope' | 'conversationWrite'> + } +): boolean { + const { plan, envelope } = request + const hostFingerprint = computeAgentSessionPayloadFingerprint({ + method: plan.method, + sessionId: envelope.sessionId, + fields: plan.fields + }) + const evaluated = request.store.evaluateMutationOperation({ + callerKey: request.callerKey, + envelope, + hostFingerprint, + now: request.now(), + ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}) + }) + return ( + evaluated !== null && + admitAgentSessionMutation({ + envelope, + hostFingerprint, + ledger: evaluated.decision, + lease: evaluated.record.lease, + ...(plan.conversationWrite ? { conversationWrite: true } : {}) + }).decision === 'admit' + ) +} From 8f6979fbd5d2db45b379cd8ff7f4a10e00fc2b88 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:58:23 -0700 Subject: [PATCH 113/269] test(native-chat): type the stop and bypass-flag fixtures without assertions --- ...uctured-agent-session-provisional.test.tsx | 23 +++++++++++-------- src/shared/tui-agent-launch-defaults.test.ts | 4 ++-- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx index 19b94e1dad29..5b59af967f3d 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx @@ -5,16 +5,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import type { StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' -const mocks = vi.hoisted(() => ({ - call: vi.fn<(target: unknown, method: string, params: unknown) => Promise>(), - hold: vi.fn<(args: { enabled?: boolean }) => void>(), - read: vi.fn<(args: { isVisible?: boolean }) => void>(), - outbox: vi.fn<(args: { fence: number | null; submissions: readonly unknown[] }) => void>(), - send: vi.fn<(text: string) => boolean>(), - retry: vi.fn<(clientMessageId: string) => void>(), - withdrawUnsent: vi.fn<() => void>(), - outboxEntries: [] as StructuredAgentSessionOutboxEntry[] -})) +const mocks = vi.hoisted(() => { + const outboxEntries: StructuredAgentSessionOutboxEntry[] = [] + return { + call: vi.fn<(target: unknown, method: string, params: unknown) => Promise>(), + hold: vi.fn<(args: { enabled?: boolean }) => void>(), + read: vi.fn<(args: { isVisible?: boolean }) => void>(), + outbox: vi.fn<(args: { fence: number | null; submissions: readonly unknown[] }) => void>(), + send: vi.fn<(text: string) => boolean>(), + retry: vi.fn<(clientMessageId: string) => void>(), + withdrawUnsent: vi.fn<() => void>(), + outboxEntries + } +}) let readState: StructuredAgentSessionState diff --git a/src/shared/tui-agent-launch-defaults.test.ts b/src/shared/tui-agent-launch-defaults.test.ts index 1a21ccf71ce2..78d8e8f2c1c1 100644 --- a/src/shared/tui-agent-launch-defaults.test.ts +++ b/src/shared/tui-agent-launch-defaults.test.ts @@ -5,7 +5,7 @@ import { tuiAgentArgsBypassPermissions } from './tui-agent-launch-defaults' import { applyAgentPermissionMode, YOLO_TUI_AGENT_ARGS } from './tui-agent-permissions' -import type { TuiAgent } from './tui-agent' +import { isTuiAgent } from './tui-agent-config' describe('tuiAgentArgsBypassPermissions', () => { // The Agent Permissions toggle has no storage of its own: Yolo is the presence of the agent's @@ -92,7 +92,7 @@ describe('tuiAgentArgsBypassPermissions', () => { describe('resolvedTuiAgentArgsBypassPermissions', () => { // The settings shape the Agent Permissions toggle writes, for every agent it covers. - it.each(Object.keys(YOLO_TUI_AGENT_ARGS) as TuiAgent[])( + it.each(Object.keys(YOLO_TUI_AGENT_ARGS).filter(isTuiAgent))( "reads the toggle's own Yolo and Manual writes for %s", (agent) => { for (const platform of ['darwin', 'win32'] as const) { From 01f92061398e06d8b6363e5dd4e8861419d95bde Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:39:50 -0700 Subject: [PATCH 114/269] test(native-chat): share one Codex journal identity in the integration suite Main grew the suite to the 800-line limit; the opaque-handle import pushed it over. The two tests built the same identity inline. --- ...ructured-agent-session-integration.test.ts | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index cebfab5bdb0d..5ff7cb3029f7 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -53,6 +53,13 @@ const SESSION = 'session-integration-1' const THREAD = 'thread-integration' const TURN = 'turn-1' const WORKSPACE = 'workspace-1' +const JOURNAL_IDENTITY = { + sessionId: SESSION, + workspaceId: WORKSPACE, + hostId: 'local', + agent: 'codex' as const, + providerHandle: codexProviderHandle(THREAD) +} // The capability set the desktop renderer advertises. Without the pending-send // one the host holds the reply until the send settles, which is a shim for // clients too old to render a pending bubble — not what this suite models. @@ -403,15 +410,8 @@ afterEach(async () => { describe('a structured codex session over agentSession.*', () => { it('hydrates provider options after activating a legacy-imported journal', async () => { - const identity = { - sessionId: SESSION, - workspaceId: WORKSPACE, - hostId: 'local', - agent: 'codex' as const, - providerHandle: codexProviderHandle(THREAD) - } const journal = await journals.open({ - identity, + identity: JOURNAL_IDENTITY, stateDirectory: root }) const rollout = join(root, 'legacy-rollout.jsonl') @@ -824,15 +824,8 @@ describe('a structured codex session over agentSession.*', () => { await stopping expect(waitedForFinalAppend).toBe(true) - const identity = { - sessionId: SESSION, - workspaceId: WORKSPACE, - hostId: 'local', - agent: 'codex' as const, - providerHandle: codexProviderHandle(THREAD) - } const reopened = await journals.open({ - identity, + identity: JOURNAL_IDENTITY, stateDirectory: root }) expect(reopened.snapshot().items.map(textOf)).toContain('Final text before shutdown.') From 987fb5307f22691c5e3d3c21c88a929af47ef24f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:42:01 -0700 Subject: [PATCH 115/269] Give provider child env one owner and gate Codex contract on the shared reader resolveProviderChildEnv is now the only place that overlays and strips a provider's environment; the spawn spec and the request-scoped Codex session both call it. supervisedPosixLaunch only accepts a launch without env fields, so an override can no longer be silently ignored there. Edits to the shared stream reader or the env rule now run the real-binary Codex contract job. --- config/scripts/pr-code-change-scope.mjs | 2 ++ config/scripts/pr-code-change-scope.test.mjs | 2 ++ config/tsconfig.cli.json | 1 + src/main/codex/codex-app-server-session.ts | 6 ++--- .../provider-process-launch.test.ts | 27 +++++++++++++++++++ .../provider-process-launch.ts | 12 +++++++++ .../provider-process-supervisor.test.ts | 14 +++++++--- .../provider-process-supervisor.ts | 23 +++++++++++----- 8 files changed, 73 insertions(+), 14 deletions(-) create mode 100644 src/main/provider-process/provider-process-launch.test.ts diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 2d0b7ac2fc01..7f9005197722 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -71,6 +71,8 @@ const CODEX_INDEX_HEAL_CONTRACT_PREFIXES = [ 'src/main/sqlite/sync-database', 'src/main/codex/codex-app-server-capability-signal', 'src/main/provider-process/provider-process-exit-deadline', + 'src/main/provider-process/provider-process-launch', + 'src/main/provider-process/provider-record-reader', 'src/main/codex/codex-session-backfill', 'src/main/codex/codex-session-index-heal-state', 'src/main/codex-cli/command', diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 65cc540c90f2..ffd19687528a 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -170,6 +170,8 @@ describe('per-job path classification', () => { for (const file of [ 'src/main/codex/codex-app-server-capability-signal.ts', 'src/main/provider-process/provider-process-exit-deadline.ts', + 'src/main/provider-process/provider-process-launch.ts', + 'src/main/provider-process/provider-record-reader.ts', 'src/main/codex/codex-session-backfill.ts', 'src/main/codex/codex-session-index-heal-state.ts', 'src/main/codex-cli/command.ts', diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 910071f62a34..934423623384 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -150,6 +150,7 @@ "../src/main/codex/codex-hook-wsl-runtime.ts", "../src/main/codex/codex-managed-trust-reconciliation.ts", "../src/main/provider-process/provider-process-exit-deadline.ts", + "../src/main/provider-process/provider-process-launch.ts", "../src/main/codex/codex-real-home-background-grant.ts", "../src/main/codex/codex-real-home-entry-trust.ts", "../src/main/codex/codex-real-home-hook-entry-plan.ts", diff --git a/src/main/codex/codex-app-server-session.ts b/src/main/codex/codex-app-server-session.ts index 8f2bcbecad2b..e46be4303296 100644 --- a/src/main/codex/codex-app-server-session.ts +++ b/src/main/codex/codex-app-server-session.ts @@ -1,5 +1,6 @@ import type { ChildProcessWithoutNullStreams } from 'node:child_process' import { waitForProcessExitUntil } from '../provider-process/provider-process-exit-deadline' +import { resolveProviderChildEnv } from '../provider-process/provider-process-launch' import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { @@ -102,10 +103,7 @@ export async function runCodexAppServerSession( ): Promise { // Why: a default-home grant must run against the real ~/.codex, so strip an // inherited CODEX_HOME (envToDelete) after applying the overlay, not before. - const childEnv: NodeJS.ProcessEnv = { ...process.env, ...invocation.env } - for (const key of invocation.envToDelete ?? []) { - delete childEnv[key] - } + const childEnv = resolveProviderChildEnv(invocation, process.env) const pairedEnv = invocation.cliPath ? withCliRuntimeOnPath(invocation.cliPath, childEnv) : childEnv diff --git a/src/main/provider-process/provider-process-launch.test.ts b/src/main/provider-process/provider-process-launch.test.ts new file mode 100644 index 000000000000..0eb40592a50a --- /dev/null +++ b/src/main/provider-process/provider-process-launch.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { resolveProviderChildEnv } from './provider-process-launch' + +describe('resolveProviderChildEnv', () => { + it('overlays the launch env on the inherited env, then strips deleted keys', () => { + const baseEnv = { PATH: '/bin', AGENT_HOME: '/inherited', PARENT_AGENT: 'parent' } + const overlay = { AGENT_HOME: '/pinned', LAUNCH_ONLY: 'added', PARENT_AGENT: 'overlay' } + + const childEnv = resolveProviderChildEnv( + { env: overlay, envToDelete: ['PARENT_AGENT'] }, + baseEnv + ) + + expect(childEnv).toEqual({ PATH: '/bin', AGENT_HOME: '/pinned', LAUNCH_ONLY: 'added' }) + expect(baseEnv).toEqual({ PATH: '/bin', AGENT_HOME: '/inherited', PARENT_AGENT: 'parent' }) + expect(overlay.PARENT_AGENT).toBe('overlay') + }) + + it('copies the inherited env when the launch carries no env', () => { + const baseEnv = { PATH: '/bin' } + + const childEnv = resolveProviderChildEnv({}, baseEnv) + + expect(childEnv).toEqual(baseEnv) + expect(childEnv).not.toBe(baseEnv) + }) +}) diff --git a/src/main/provider-process/provider-process-launch.ts b/src/main/provider-process/provider-process-launch.ts index 326e1d17adcb..f7c527cd5824 100644 --- a/src/main/provider-process/provider-process-launch.ts +++ b/src/main/provider-process/provider-process-launch.ts @@ -8,3 +8,15 @@ export type ProviderProcessLaunch = { /** Keys stripped after the overlay. */ envToDelete?: readonly string[] } + +/** The one env rule for provider children: inherit, overlay, then strip (so a delete beats an overlay). */ +export function resolveProviderChildEnv( + launch: Pick, + baseEnv: NodeJS.ProcessEnv +): NodeJS.ProcessEnv { + const childEnv: NodeJS.ProcessEnv = { ...baseEnv, ...launch.env } + for (const key of launch.envToDelete ?? []) { + delete childEnv[key] + } + return childEnv +} diff --git a/src/main/provider-process/provider-process-supervisor.test.ts b/src/main/provider-process/provider-process-supervisor.test.ts index dcca6feeec05..a35f8102d6b3 100644 --- a/src/main/provider-process/provider-process-supervisor.test.ts +++ b/src/main/provider-process/provider-process-supervisor.test.ts @@ -14,11 +14,12 @@ const launch: ProviderProcessLaunch = { cwd: '/work/repo', env: { CODEX_HOME: '/tmp/codex' } } +const command = { command: launch.command, args: launch.args, cwd: launch.cwd } describe('structured provider supervision', () => { it('wraps POSIX launches in a detached supervisor and preserves the launch spec', () => { const childEnv = { PATH: '/bin', CODEX_HOME: '/tmp/codex' } - const spec = supervisedPosixLaunch(launch, childEnv) + const spec = supervisedPosixLaunch(command, childEnv) expect(spec.command).toBe(process.execPath) expect(spec.args).toEqual(['-e', POSIX_PROVIDER_SUPERVISOR_SCRIPT]) @@ -49,11 +50,18 @@ describe('structured provider supervision', () => { expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).not.toContain('process.ppid === 1') }) + it('only accepts a resolved env, never a launch whose env it would ignore', () => { + // @ts-expect-error env/envToDelete are resolved by createProviderSpawnSpec, not here. + const spec = supervisedPosixLaunch(launch, { PATH: '/bin' }) + + expect(spec.env).not.toHaveProperty('CODEX_HOME') + }) + it('refuses a grace longer than recovery waits before SIGKILL', () => { const stdinEnd = (stdinEndGraceMs: number) => () => - supervisedPosixLaunch(launch, {}, { stdinEndGraceMs }) + supervisedPosixLaunch(command, {}, { stdinEndGraceMs }) const sigterm = (sigtermGraceMs: number) => () => - supervisedPosixLaunch(launch, {}, { sigtermGraceMs }) + supervisedPosixLaunch(command, {}, { sigtermGraceMs }) expect(stdinEnd(PROVIDER_STDIN_END_GRACE_MS)).not.toThrow() expect(stdinEnd(PROVIDER_STDIN_END_GRACE_MS + 1)).toThrow(RangeError) diff --git a/src/main/provider-process/provider-process-supervisor.ts b/src/main/provider-process/provider-process-supervisor.ts index a446167b2f05..ac47a4a51120 100644 --- a/src/main/provider-process/provider-process-supervisor.ts +++ b/src/main/provider-process/provider-process-supervisor.ts @@ -1,4 +1,4 @@ -import type { ProviderProcessLaunch } from './provider-process-launch' +import { resolveProviderChildEnv, type ProviderProcessLaunch } from './provider-process-launch' /** Time the provider gets to exit on its own after its stdin ends, before SIGTERM. */ export const PROVIDER_STDIN_END_GRACE_MS = 1_000 @@ -138,8 +138,14 @@ function assertGraceWithin(name: string, graceMs: number, maxMs: number): void { } } +// `never` makes an env-bearing launch a type error here: env is resolved before supervision. +type ProviderSupervisedCommand = Pick & { + env?: never + envToDelete?: never +} + export function supervisedPosixLaunch( - launch: ProviderProcessLaunch, + launch: ProviderSupervisedCommand, childEnv: NodeJS.ProcessEnv, { cwd = launch.cwd ?? process.cwd(), @@ -186,11 +192,14 @@ export function createProviderSpawnSpec( /** The child is the supervisor, whose SIGTERM stops the provider and then itself. */ supervised: boolean } { - const childEnv: NodeJS.ProcessEnv = { ...baseEnv, ...launch.env } - for (const key of launch.envToDelete ?? []) { - delete childEnv[key] - } - const supervisor = platform === 'win32' ? null : supervisedPosixLaunch(launch, childEnv) + const childEnv = resolveProviderChildEnv(launch, baseEnv) + const supervisor = + platform === 'win32' + ? null + : supervisedPosixLaunch( + { command: launch.command, args: launch.args, cwd: launch.cwd }, + childEnv + ) return { program: supervisor?.command ?? launch.command, args: supervisor?.args ?? launch.args, From 03b9f2f3d2b358d7ea3e64e0a0f680feda58f6b0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:13:40 -0700 Subject: [PATCH 116/269] Drop the provider item reference, join index and identity schemes from the transition PR Nothing in production reaches the state they defended (an assembler that lost its memory while its child keeps streaming the same turn), and the stored Codex id was positional. The legacy identity scheme moves to the assembler PR with its first caller; the Codex scheme and any persisted reference wait for Codex to move onto the assembler. The Codex ordinal counter goes back to codex/, since no neutral code imports it. --- config/scripts/ci-shard-timings.json | 2 +- .../codex/codex-provider-timeline-identity.ts | 47 --- .../codex-structured-item-translation.test.ts | 28 +- .../codex-structured-item-translation.ts | 8 +- .../codex/codex-structured-journal-items.ts | 4 +- .../codex-structured-journal-settlement.ts | 4 +- ...-structured-journal-translation-restore.ts | 7 +- ...ctured-journal-translation-streams.test.ts | 4 +- ...ex-structured-journal-translation-turns.ts | 2 +- src/main/codex/codex-thread-item-identity.ts | 8 +- .../codex-turn-ordinals.test.ts} | 25 +- .../codex-turn-ordinals.ts} | 19 +- .../journal-item-fold.ts | 10 +- .../journal-provider-item-ref.test.ts | 75 ----- .../agent-session-journal/journal-reducer.ts | 3 - .../journal-render-item.ts | 1 - .../journal-row-builders.ts | 5 +- .../journal-row-schema.ts | 12 +- .../journal-store-contracts.ts | 5 +- .../agent-session-journal/journal-store.ts | 2 - .../provider-timeline-identity.ts | 138 -------- .../provider-timeline-joins.test.ts | 231 ------------- .../provider-timeline-joins.ts | 306 ------------------ .../provider-timeline-message-places.ts | 122 ------- .../structured-agent-session-event-sink.ts | 2 - ...ed-agent-session-journal-append-options.ts | 1 - src/shared/agent-session-journal-types.ts | 9 - ...n-provider-item-ref-downgrade.unit.test.ts | 127 -------- 28 files changed, 59 insertions(+), 1148 deletions(-) delete mode 100644 src/main/codex/codex-provider-timeline-identity.ts rename src/main/{native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts => codex/codex-turn-ordinals.test.ts} (64%) rename src/main/{native-chat/agent-session-timeline/provider-turn-message-ordinals.ts => codex/codex-turn-ordinals.ts} (88%) delete mode 100644 src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts delete mode 100644 tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index fb242a4788c8..73d0226943b8 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -1307,7 +1307,7 @@ "src/main/codex/codex-trust-grant-ledger.test.ts": 54, "src/main/codex/codex-trust-grant-main-thread-boundary.test.ts": 43, "src/main/codex/codex-trust-grant-telemetry.test.ts": 70, - "src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts": 96, + "src/main/codex/codex-turn-ordinals.test.ts": 96, "src/main/codex/codex-user-hook-trust-rebase-client.test.ts": 104, "src/main/codex/codex-user-hook-trust-moves.test.ts": 127, "src/main/codex/codex-wsl-hook-install-plan.test.ts": 51, diff --git a/src/main/codex/codex-provider-timeline-identity.ts b/src/main/codex/codex-provider-timeline-identity.ts deleted file mode 100644 index 7da2bbd33aae..000000000000 --- a/src/main/codex/codex-provider-timeline-identity.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Codex's persisted identities, spelled for the shared timeline assembler. -// -// The same rows Codex writes today (`codexItemIdentity`, `codexTurnLifecycleIdentity`): a user or -// assistant message inside a turn keys by (thread, turn, message ordinal), which survives a resume -// because the ordinal counts messages only; anything else keys by its Codex item id; a turn keys by -// its Codex turn id. Codex ids name one conversation for good, so the namespace spells nothing. - -import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { ProviderTimelineIdentityScheme } from '../native-chat/agent-session-timeline/provider-timeline-identity' -import { - codexTurnLifecycleIdentity, - codexTurnUserItemId -} from './codex-structured-journal-translation-turns' - -export function createCodexProviderTimelineIdentityScheme(input: { - sessionId: string - primaryThreadId: () => string | null -}): ProviderTimelineIdentityScheme { - const turn: ProviderTimelineIdentityScheme['turn'] = (address) => - codexTurnLifecycleIdentity(input.sessionId, address.key.value) - return { - ordinalMessages: true, - messageSlot: (identity) => - identity.provider === 'codex' - ? { place: `${identity.threadId}\u001f${identity.turnId}`, ordinal: identity.ordinal } - : null, - turn, - turnId: (address) => address.key.value, - turnOpener: (address) => { - const thread = input.primaryThreadId() - return thread === null - ? agentJournalItemKey(turn(address)) - : codexTurnUserItemId(thread, address.key.value) - }, - item: (address) => { - const threadId = address.thread ?? input.primaryThreadId() ?? '' - return address.itemClass === 'message' && address.turn && address.messageOrdinal !== null - ? { - provider: 'codex', - threadId, - turnId: address.turn.value, - ordinal: address.messageOrdinal - } - : { provider: 'orca', clientMessageId: `codex-item:${threadId}:${address.key.value}` } - } - } -} diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index 29562552a02c..ad63d624f58b 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -11,9 +11,9 @@ import { codexJournalItem, codexMessageBlocks, codexStreamingJournalItem, - ProviderTurnMessageOrdinals, - MAX_PROVIDER_TURN_ORDINAL_BYTES, - MAX_PROVIDER_TURN_ORDINAL_ENTRIES, + CodexTurnOrdinals, + MAX_CODEX_TURN_ORDINAL_BYTES, + MAX_CODEX_TURN_ORDINAL_ENTRIES, isCodexMessageItemType, readCodexThreadItem, type CodexThreadItem @@ -59,7 +59,7 @@ const RESUMED_TURN: CodexThreadItem[] = [ ] function keysFor(items: CodexThreadItem[]): string[] { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() return items .filter((item) => isCodexMessageItemType(item.type)) .map((item) => @@ -71,15 +71,15 @@ function keysFor(items: CodexThreadItem[]): string[] { describe('codex turn ordinals', () => { it('bounds forgotten turn tombstones while retaining the recent window', () => { - const ordinals = new ProviderTurnMessageOrdinals() - const total = MAX_PROVIDER_TURN_ORDINAL_ENTRIES + 12 + const ordinals = new CodexTurnOrdinals() + const total = MAX_CODEX_TURN_ORDINAL_ENTRIES + 12 for (let index = 0; index < total; index += 1) { const turnId = `turn-${index}` expect(ordinals.ordinalFor('thread-many', turnId, 'item-0')).toBe(0) ordinals.forgetTurn('thread-many', turnId) } - expect(ordinals.forgottenTurnCount).toBe(MAX_PROVIDER_TURN_ORDINAL_ENTRIES) + expect(ordinals.forgottenTurnCount).toBe(MAX_CODEX_TURN_ORDINAL_ENTRIES) // The newest completed turn still keeps its counter for a late frame. expect(ordinals.ordinalFor('thread-many', `turn-${total - 1}`, 'item-late')).toBe(1) // The oldest turn was deterministically evicted and starts a fresh key. @@ -87,7 +87,7 @@ describe('codex turn ordinals', () => { }) it('releases a forgotten turn without ever reusing an ordinal it assigned', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-1')).toBe(0) expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-2')).toBe(1) @@ -102,12 +102,12 @@ describe('codex turn ordinals', () => { }) it('bounds aggregate provider identifier bytes retained by one active turn', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() for (let index = 0; index < 3_000; index += 1) { ordinals.ordinalFor('thread', 'turn', `${index}:${'x'.repeat(512)}`) } - expect(ordinals.bytes).toBeLessThanOrEqual(MAX_PROVIDER_TURN_ORDINAL_BYTES) + expect(ordinals.bytes).toBeLessThanOrEqual(MAX_CODEX_TURN_ORDINAL_BYTES) }) }) @@ -117,7 +117,7 @@ describe('codex item identity', () => { }) it('numbers messages 0,1,2 on both sides — the projection skips the dropped command', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() const live = LIVE_TURN.map((item) => codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, item, ordinals }) ) @@ -136,7 +136,7 @@ describe('codex item identity', () => { }) it('assigns an ordinal once and reuses it, so a delta and its completion upsert one row', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0') expect(ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-1')).toBe(1) @@ -144,14 +144,14 @@ describe('codex item identity', () => { }) it('restarts numbering per turn', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0') expect(ordinals.ordinalFor(THREAD_ID, 'turn-2', 'item-1')).toBe(0) }) it('keys a non-message item and a turnless message in the orca namespace', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() const command = codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 3f4229cb6e54..25b821d90d9f 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -28,10 +28,10 @@ export { type CodexThreadItem } from './codex-thread-item-identity' export { - ProviderTurnMessageOrdinals, - MAX_PROVIDER_TURN_ORDINAL_BYTES, - MAX_PROVIDER_TURN_ORDINAL_ENTRIES -} from '../native-chat/agent-session-timeline/provider-turn-message-ordinals' + CodexTurnOrdinals, + MAX_CODEX_TURN_ORDINAL_BYTES, + MAX_CODEX_TURN_ORDINAL_ENTRIES +} from './codex-turn-ordinals' // Codex thread items → journal item bodies. diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index 22bb16c8ef08..ec6ac67df5d4 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -7,7 +7,7 @@ import { requiresTerminalSettlement } from '../native-chat/agent-session-journal import { codexItemIdentity, codexJournalItem, - ProviderTurnMessageOrdinals, + CodexTurnOrdinals, readCodexThreadItem, type CodexThreadItem } from './codex-structured-item-translation' @@ -35,7 +35,7 @@ import { readCodexDispatchEcho } from './codex-structured-dispatch-echo' import type { CodexRowAttribution } from './codex-subagent-linkage' export class CodexJournalItems { - readonly ordinals = new ProviderTurnMessageOrdinals() + readonly ordinals = new CodexTurnOrdinals() readonly activeItems = new Map() readonly streams private readonly identities = new Map() diff --git a/src/main/codex/codex-structured-journal-settlement.ts b/src/main/codex/codex-structured-journal-settlement.ts index e7d0a376eca8..131de9824aae 100644 --- a/src/main/codex/codex-structured-journal-settlement.ts +++ b/src/main/codex/codex-structured-journal-settlement.ts @@ -17,7 +17,7 @@ import { codexJournalItem, codexStreamingJournalItem, type CodexThreadItem, - type ProviderTurnMessageOrdinals + type CodexTurnOrdinals } from './codex-structured-item-translation' import type { CodexStructuredItemStreams } from './codex-structured-item-streams' import type { CodexHelperName } from './codex-collab-agent-item-translation' @@ -56,7 +56,7 @@ export function settleCodexJournalSession(input: { pendingPrompts: ReadonlyMap currentTurnIds: ReadonlyMap> primaryThreadId: string | null - ordinals: ProviderTurnMessageOrdinals + ordinals: CodexTurnOrdinals /** Terminal lifecycle for a turn the provider left running when it ended; null for a turn a * conversation command claimed, whose record the host settles. */ settledTurnLifecycle: (threadId: string, turnId: string) => AgentJournalTurnLifecycle | null diff --git a/src/main/codex/codex-structured-journal-translation-restore.ts b/src/main/codex/codex-structured-journal-translation-restore.ts index a3b4652f366e..9f7efc15ae4c 100644 --- a/src/main/codex/codex-structured-journal-translation-restore.ts +++ b/src/main/codex/codex-structured-journal-translation-restore.ts @@ -1,8 +1,5 @@ import type { AgentJournalTurnLifecycle } from '../../shared/agent-session-journal-types' -import { - readCodexThreadItem, - type ProviderTurnMessageOrdinals -} from './codex-structured-item-translation' +import { readCodexThreadItem, type CodexTurnOrdinals } from './codex-structured-item-translation' import type { CodexJournalCompactions } from './codex-structured-journal-compactions' import type { CodexJournalItems } from './codex-structured-journal-items' import { @@ -58,7 +55,7 @@ export function restoreCodexJournalThread(input: { threadId: string thread: Record currentTurnIds: Map> - ordinals: ProviderTurnMessageOrdinals + ordinals: CodexTurnOrdinals handleItem: (event: CodexHistoryItemEvent) => CodexJournalTranslationAdmission /** Absent when the caller has no session identity to key lifecycle rows by. */ restoreTurnLifecycle?: ( diff --git a/src/main/codex/codex-structured-journal-translation-streams.test.ts b/src/main/codex/codex-structured-journal-translation-streams.test.ts index 7bb88edc3a6f..54a2c017aa05 100644 --- a/src/main/codex/codex-structured-journal-translation-streams.test.ts +++ b/src/main/codex/codex-structured-journal-translation-streams.test.ts @@ -6,7 +6,7 @@ import type { import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { projectStructuredItemsToNativeChat } from '../../shared/structured-agent-session-projection' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { ProviderTurnMessageOrdinals } from './codex-structured-item-translation' +import { CodexTurnOrdinals } from './codex-structured-item-translation' import { createCodexJournalTranslator, MAX_CODEX_GENERIC_BOOKKEEPING_ENTRIES, @@ -164,7 +164,7 @@ describe('codex journal translation', () => { }) it('releases a turn ordinal map when the turn completes', () => { - const spy = vi.spyOn(ProviderTurnMessageOrdinals.prototype, 'forgetTurn') + const spy = vi.spyOn(CodexTurnOrdinals.prototype, 'forgetTurn') try { const { translator } = translatorWith() translator.handle(TURN_STARTED) diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts index dc955f70385c..33f138c6578d 100644 --- a/src/main/codex/codex-structured-journal-translation-turns.ts +++ b/src/main/codex/codex-structured-journal-translation-turns.ts @@ -8,7 +8,7 @@ import { } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' -import { CODEX_USER_MESSAGE_ORDINAL } from './codex-thread-item-identity' +import { CODEX_USER_MESSAGE_ORDINAL } from './codex-turn-ordinals' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission diff --git a/src/main/codex/codex-thread-item-identity.ts b/src/main/codex/codex-thread-item-identity.ts index 663b497f7731..0488e5c00e98 100644 --- a/src/main/codex/codex-thread-item-identity.ts +++ b/src/main/codex/codex-thread-item-identity.ts @@ -1,5 +1,5 @@ import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' -import type { ProviderTurnMessageOrdinals } from '../native-chat/agent-session-timeline/provider-turn-message-ordinals' +import type { CodexTurnOrdinals } from './codex-turn-ordinals' // Codex thread items → durable journal identities. // @@ -15,10 +15,6 @@ import type { ProviderTurnMessageOrdinals } from '../native-chat/agent-session-t // including ones this build does not model — is skipped identically on both // sides, which is what makes the key survive a Codex release that adds one. -/** Codex records the user message first in a turn, so a restored submission is - * ordinal 0 of `(threadId, turnId)`. */ -export const CODEX_USER_MESSAGE_ORDINAL = 0 - /** Only these carry a durable `(threadId, turnId, ordinal)` identity. */ const CODEX_MESSAGE_ITEM_TYPES = new Set(['userMessage', 'agentMessage']) @@ -54,7 +50,7 @@ export function codexItemIdentity(input: { threadId: string turnId: string | null item: CodexThreadItem - ordinals: ProviderTurnMessageOrdinals + ordinals: CodexTurnOrdinals }): AgentJournalItemIdentity { const { item, turnId } = input if (turnId && isCodexMessageItemType(item.type)) { diff --git a/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts b/src/main/codex/codex-turn-ordinals.test.ts similarity index 64% rename from src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts rename to src/main/codex/codex-turn-ordinals.test.ts index b070b449f264..5ae12011a855 100644 --- a/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts +++ b/src/main/codex/codex-turn-ordinals.test.ts @@ -1,17 +1,16 @@ import { expect, it } from 'vitest' import { - ProviderTurnMessageOrdinals, - MAX_PROVIDER_TURN_ORDINAL_BYTES, - MAX_PROVIDER_TURN_ORDINAL_ENTRIES -} from './provider-turn-message-ordinals' + CodexTurnOrdinals, + MAX_CODEX_TURN_ORDINAL_BYTES, + MAX_CODEX_TURN_ORDINAL_ENTRIES +} from './codex-turn-ordinals' it('does not rescan the forgotten window for each new streamed item', () => { - const ordinals = new ProviderTurnMessageOrdinals() - for (let index = 0; index < MAX_PROVIDER_TURN_ORDINAL_ENTRIES; index += 1) { + const ordinals = new CodexTurnOrdinals() + for (let index = 0; index < MAX_CODEX_TURN_ORDINAL_ENTRIES; index += 1) { ordinals.ordinalFor('thread', String(index), 'item') ordinals.forgetTurn('thread', String(index)) } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this test counts reads of the class's private turn map, whose entries carry `active`. const turns = (ordinals as unknown as { turns: Map }).turns let reads = 0 for (const turn of turns.values()) { @@ -30,30 +29,30 @@ it('does not rescan the forgotten window for each new streamed item', () => { expect(ordinals.ordinalFor('thread', 'live', String(index))).toBe(index) } expect(reads).toBe(0) - expect(ordinals.forgottenTurnCount).toBe(MAX_PROVIDER_TURN_ORDINAL_ENTRIES) + expect(ordinals.forgottenTurnCount).toBe(MAX_CODEX_TURN_ORDINAL_ENTRIES) }) it('retains ordinal continuity on late reactivation and evicts oldest forgotten turns', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() expect(ordinals.ordinalFor('t', 'first', 'a')).toBe(0) ordinals.forgetTurn('t', 'first') expect(ordinals.ordinalFor('t', 'first', 'b')).toBe(1) expect(ordinals.forgottenTurnCount).toBe(0) ordinals.forgetTurn('t', 'first') - for (let index = 0; index < MAX_PROVIDER_TURN_ORDINAL_ENTRIES; index += 1) { + for (let index = 0; index < MAX_CODEX_TURN_ORDINAL_ENTRIES; index += 1) { ordinals.ordinalFor('t', String(index), 'a') ordinals.forgetTurn('t', String(index)) } - expect(ordinals.forgottenTurnCount).toBe(MAX_PROVIDER_TURN_ORDINAL_ENTRIES) + expect(ordinals.forgottenTurnCount).toBe(MAX_CODEX_TURN_ORDINAL_ENTRIES) expect(ordinals.ordinalFor('t', 'first', 'c')).toBe(0) }) it('keeps byte eviction bounded for active and forgotten turns', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() for (let index = 0; index < 4000; index += 1) { ordinals.ordinalFor('thread', 'live', `${index}-${'x'.repeat(240)}`) } - expect(ordinals.bytes).toBeLessThanOrEqual(MAX_PROVIDER_TURN_ORDINAL_BYTES) + expect(ordinals.bytes).toBeLessThanOrEqual(MAX_CODEX_TURN_ORDINAL_BYTES) ordinals.forgetTurn('thread', 'live') expect(ordinals.bytes).toBeLessThan(100) expect(ordinals.forgottenTurnCount).toBe(1) diff --git a/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.ts b/src/main/codex/codex-turn-ordinals.ts similarity index 88% rename from src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.ts rename to src/main/codex/codex-turn-ordinals.ts index 9405ff9281ef..7e21b900d965 100644 --- a/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.ts +++ b/src/main/codex/codex-turn-ordinals.ts @@ -1,11 +1,18 @@ -import { boundPayload, digestPayload } from '../agent-session-journal/journal-payload-bounds' +import { + boundPayload, + digestPayload +} from '../native-chat/agent-session-journal/journal-payload-bounds' + +/** Codex records the user message first in a turn, so a restored submission is + * ordinal 0 of `(threadId, turnId)`. */ +export const CODEX_USER_MESSAGE_ORDINAL = 0 /** Maximum forgotten turn keys retained for late-frame reconciliation. */ -export const MAX_PROVIDER_TURN_ORDINAL_ENTRIES = 256 -export const MAX_PROVIDER_TURN_ORDINAL_BYTES = 512 * 1024 +export const MAX_CODEX_TURN_ORDINAL_ENTRIES = 256 +export const MAX_CODEX_TURN_ORDINAL_BYTES = 512 * 1024 /** Assigns stable message ordinals while retaining a bounded late-frame window. */ -export class ProviderTurnMessageOrdinals { +export class CodexTurnOrdinals { private readonly turns = new Map< string, { assigned: Map; next: number; active: boolean } @@ -39,7 +46,7 @@ export class ProviderTurnMessageOrdinals { } private trimForgotten(): void { - while (this.forgottenTurnCount > MAX_PROVIDER_TURN_ORDINAL_ENTRIES) { + while (this.forgottenTurnCount > MAX_CODEX_TURN_ORDINAL_ENTRIES) { const oldest = this.forgottenTurns.values().next().value if (!oldest) { break @@ -60,7 +67,7 @@ export class ProviderTurnMessageOrdinals { private trimBytes(currentTurnKey: string): void { this.trimForgotten() - while (this.retainedBytes > MAX_PROVIDER_TURN_ORDINAL_BYTES) { + while (this.retainedBytes > MAX_CODEX_TURN_ORDINAL_BYTES) { const forgotten = this.forgottenTurns.values().next().value const oldest = forgotten ?? this.turns.keys().next().value if (typeof oldest !== 'string') { diff --git a/src/main/native-chat/agent-session-journal/journal-item-fold.ts b/src/main/native-chat/agent-session-journal/journal-item-fold.ts index aa1a02e4a70a..10aee4a99c41 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-fold.ts @@ -39,9 +39,6 @@ export function upsertJournalItem( return } state.derivedTurnScope.observe(itemId, isRootAgentJournalItem(next), existing?.body, next.body) - if (next.providerItemRef !== undefined && existing?.providerItemRef === undefined) { - state.providerItemRefs.set(next.providerItemRef, itemId) - } if (!existing) { state.items.set(itemId, next) state.itemFences.set(itemId, fence) @@ -68,9 +65,7 @@ export function upsertJournalItem( sequence: existing.sequence, ...(existing.sequenceIndex !== undefined ? { sequenceIndex: existing.sequenceIndex } : {}), observedAt: existing.observedAt, - turnScope: existing.turnScope ?? next.turnScope, - // The creating write's, as the scope is: a revision never re-points the row at another item. - ...(existing.providerItemRef === undefined ? {} : { providerItemRef: existing.providerItemRef }) + turnScope: existing.turnScope ?? next.turnScope }) state.tombstones.delete(itemId) } @@ -97,9 +92,6 @@ export function removeJournalItem( ) } state.tombstones.set(itemId, revision) - if (existing?.providerItemRef !== undefined) { - state.providerItemRefs.delete(existing.providerItemRef) - } state.items.delete(itemId) state.itemFences.delete(itemId) } diff --git a/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts b/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts deleted file mode 100644 index d80d5a539965..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' -import { createTrackedJournalOpener } from './journal-host-database-test-support' - -const journals = createTrackedJournalOpener() -const roots: string[] = [] - -afterEach(async () => { - await journals.closeAll() - for (const root of roots.splice(0)) { - await rm(root, { recursive: true, force: true }) - } -}) - -async function open(root?: string) { - const directory = root ?? (await mkdtemp(join(tmpdir(), 'orca-provider-item-ref-'))) - if (!root) { - roots.push(directory) - } - const journal = await journals.open({ - identity: { - sessionId: 'session-ref', - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'root' } - }, - stateDirectory: directory, - now: () => 1_000 - }) - return { journal, directory } -} - -const row: AgentJournalItemIdentity = { - provider: 'codex', - threadId: 'root', - turnId: 't1', - ordinal: 0 -} -const text = (value: string) => ({ - kind: 'message' as const, - role: 'assistant' as const, - blocks: [{ type: 'text' as const, text: value }] -}) -const scope = { kind: 'turn', turnItemId: 'turn:t1' } as const - -describe('a row’s provider item reference', () => { - it('is the creating write’s, kept by revisions and indexed for its producer', async () => { - const { journal } = await open() - await journal.appendItem(row, text('a'), { fence: 1, turnScope: scope, providerItemRef: 'm0' }) - await journal.appendItem(row, text('ab'), { fence: 1, turnScope: scope, providerItemRef: 'm9' }) - await journal.appendItem(row, text('abc'), { fence: 1, turnScope: scope }) - - expect(journal.item('codex:root:t1:0')).toMatchObject({ - providerItemRef: 'm0', - body: text('abc') - }) - expect(journal.itemIdForProviderItemRef('m0')).toBe('codex:root:t1:0') - expect(journal.itemIdForProviderItemRef('m9')).toBeNull() - }) - - it('survives a reopen, and leaves the index with its row', async () => { - const { journal, directory } = await open() - await journal.appendItem(row, text('a'), { fence: 1, turnScope: scope, providerItemRef: 'm0' }) - await journals.closeAll() - - const { journal: reopened } = await open(directory) - expect(reopened.itemIdForProviderItemRef('m0')).toBe('codex:root:t1:0') - await reopened.appendTombstone(row, { fence: 1 }) - expect(reopened.itemIdForProviderItemRef('m0')).toBeNull() - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index c4e892de8038..e2d1b1ef3ca3 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -56,8 +56,6 @@ export type JournalReducerState = { /** Provider item id → the submission slot that adopted it. Stops an accepted * echo from appending a second copy of the user's own message. */ aliases: Map - /** `providerItemRef` → the row that carries it, so a producer finds that row again in O(1). */ - providerItemRefs: Map appliedSettlementIds: Set /** Scope for rows stored without one; rebuilt by replay, never persisted. */ derivedTurnScope: JournalDerivedTurnScope @@ -82,7 +80,6 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou submissions: new Map(), receipts: new Map(), aliases: new Map(), - providerItemRefs: new Map(), appliedSettlementIds: new Set(), derivedTurnScope: new JournalDerivedTurnScope(), latestPersonTurnSequence: 0, diff --git a/src/main/native-chat/agent-session-journal/journal-render-item.ts b/src/main/native-chat/agent-session-journal/journal-render-item.ts index 76a7abf054ec..fd58b2c25e4e 100644 --- a/src/main/native-chat/agent-session-journal/journal-render-item.ts +++ b/src/main/native-chat/agent-session-journal/journal-render-item.ts @@ -35,7 +35,6 @@ export function journalRenderItem( ...(row.recovered ? { recoveredAt: row.ts } : {}), ...(row.recovered ? { recovered: row.recovered } : {}), turnScope, - ...(row.providerItemRef === undefined ? {} : { providerItemRef: row.providerItemRef }), ...agentJournalLinkageFields(producer) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 16bf1960e400..0865df3a41fa 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -49,8 +49,7 @@ export function journalItemRowBuilder( ts: options.observedAt ?? ts, recovered: options.recovered, linkage: options, - turnScope: options.turnScope, - ...(options.providerItemRef === undefined ? {} : { providerItemRef: options.providerItemRef }) + turnScope: options.turnScope }) } @@ -250,7 +249,6 @@ export function buildJournalItemRow(input: { recovered?: true linkage?: AgentJournalProducerLinkage turnScope: AgentJournalTurnScope - providerItemRef?: string }): JournalItemRow { const itemId = agentJournalItemKey(input.identity) const resolved = input.state.aliases.get(itemId) ?? itemId @@ -270,7 +268,6 @@ export function buildJournalItemRow(input: { ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [body]), ...(input.recovered ? { recovered: input.recovered } : {}), turnScope: input.turnScope, - ...(input.providerItemRef === undefined ? {} : { providerItemRef: input.providerItemRef }), ...agentJournalLinkageFields(input.linkage) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index e8d1746ceb8d..9276b1a8c05f 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -20,7 +20,6 @@ import { type AgentJournalItemBody, type AgentJournalMessageItem, type AgentJournalProducerLinkage, - type AgentJournalProviderItemRef, type AgentJournalTurnScope, type AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' @@ -52,9 +51,6 @@ type JournalRowBase = AgentJournalProducerLinkage & { * for the reason linkage does. Absent on rows from hosts that predate it: the reducer * derives one for them on read. */ turnScope?: AgentJournalTurnScope - /** On an item row: the provider's reference for the item it creates. Rides the base for the - * reason linkage does. */ - providerItemRef?: AgentJournalProviderItemRef } /** First row of every epoch: binds the epoch to a provider handle and records why it opened. */ @@ -253,13 +249,7 @@ export function parseJournalRow(line: string): JournalRowParse { * survives is a real one: the reader scopes on PRESENCE, so `''` or a * non-string left in place would hide the row from its own author for good. */ function dropUnusableProducerLinkage(record: Record): void { - for (const field of [ - 'agentId', - 'parentAgentId', - 'providerParentRef', - 'producerKind', - 'providerItemRef' - ]) { + for (const field of ['agentId', 'parentAgentId', 'providerParentRef', 'producerKind']) { const value = record[field] if (value !== undefined && (typeof value !== 'string' || value.length === 0)) { delete record[field] diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index b5a960360368..a85b3adc3172 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -62,10 +62,7 @@ export type JournalItemLinkageVisitor = ( itemId: string, sequence: number, body: AgentJournalItemBody, - attribution: AgentJournalProducerLinkage & { - turnScope?: AgentJournalTurnScope - providerItemRef?: string - } + attribution: AgentJournalProducerLinkage & { turnScope?: AgentJournalTurnScope } ) => void export type JournalLifecycleBatchInput = { diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 19a8b61a62cd..d12257447cfd 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -230,8 +230,6 @@ export class AgentSessionJournal { /** One reduced item with its attribution, for a writer that needs the turn a row joined. */ item = (itemId: string): AgentJournalRenderItem | null => this.state.items.get(itemId) ?? null - /** The row that carries this `providerItemRef`, if any. */ - itemIdForProviderItemRef = (ref: string) => this.state.providerItemRefs.get(ref) ?? null /** Visits reduced items with the producer that wrote each, for a producer re-deriving what an * earlier run of this session left. */ diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts deleted file mode 100644 index 77286d029055..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts +++ /dev/null @@ -1,138 +0,0 @@ -// How the assembler's addresses become persisted journal identities. -// -// The assembler decides WHICH rows are the same row: it resolves every provider join to an -// address — the provider session's namespace, the key, the thread and turn the item belongs to, -// the item's class, and its place among its turn's messages — or mints a key when the provider -// names nothing. A scheme only spells an address as an `AgentJournalItemIdentity`. It must be pure, -// and it is the one place a provider's persisted identity shape lives, so moving a provider to a -// new identity arm is a change here. - -import type { - AgentJournalItemBody, - AgentJournalItemIdentity, - AgentType -} from '../../../shared/agent-session-journal-types' -import { boundPayload, digestPayload } from '../agent-session-journal/journal-payload-bounds' - -/** A key the provider vouched for, or one the assembler minted because it named nothing. - * A minted value is unique per acquisition, so it never needs a namespace. */ -export type ProviderTimelineKey = { source: 'provider' | 'minted'; value: string } - -export type ProviderTimelineTurnAddress = { - /** The provider session whose ids the key belongs to. */ - namespace: string - key: ProviderTimelineKey -} - -/** Which family an item belongs to; families never share a key space. Streamed text and full - * item snapshots are one family, so a provider-named message is one row however it arrives. */ -export type ProviderTimelineItemFamily = 'item' | 'request' | 'frame' - -/** What kind of thing an item is, derived from what it carries: `message` is user or assistant - * prose; every other class is its row kind (`reasoning` text included). */ -export type ProviderTimelineItemClass = - | 'message' - | 'reasoning' - | Exclude - | 'frame' - -export type ProviderTimelineItemAddress = { - namespace: string - family: ProviderTimelineItemFamily - key: ProviderTimelineKey - /** The provider thread it came from; null when the provider named none. */ - thread: string | null - /** The provider turn it belongs to; null for a row outside any turn. */ - turn: ProviderTimelineKey | null - itemClass: ProviderTimelineItemClass - /** Its place among its (thread, turn)'s `message` items, by first write; null for any other, and - * for every item of a scheme without `ordinalMessages`. */ - messageOrdinal: number | null - /** 1 for the first request under a key; a key reused after it settled is the next one. */ - incarnation: number -} - -export type ProviderTimelineMessageSlot = { place: string; ordinal: number } - -export type ProviderTimelineIdentityScheme = { - /** Messages inside a turn are keyed by their place among the turn's messages, not by their key. - * Such an identity must spell only (thread, turn, ordinal), and its row carries the key as - * `providerItemRef`, since nothing else could find it again. */ - ordinalMessages: boolean - /** Reads an ordinal message identity back: its (thread, turn) place, spelled by the scheme, and - * its ordinal; null for any other identity. Required with `ordinalMessages`. */ - messageSlot?(identity: AgentJournalItemIdentity): ProviderTimelineMessageSlot | null - turn(address: ProviderTimelineTurnAddress): AgentJournalItemIdentity - /** The id the turn row carries and a client's Stop names. */ - turnId(address: ProviderTimelineTurnAddress): string - /** The user item a turn names when no send of Orca's opened it; absent ⇒ the turn row itself. */ - turnOpener?(address: ProviderTimelineTurnAddress): string - /** With a null `messageOrdinal` it must spell only (namespace, family, key, thread, incarnation), - * so a row is found again from its key alone. */ - item(address: ProviderTimelineItemAddress): AgentJournalItemIdentity -} - -export function providerTimelineItemClass(body: AgentJournalItemBody): ProviderTimelineItemClass { - if (body.kind !== 'message') { - return body.kind - } - return body.role === 'user' || body.role === 'assistant' ? 'message' : 'reasoning' -} - -const MAX_KEY_PART_BYTES = 256 - -/** A provider id as a bounded identity part: escaped so `:` cannot forge another part, and - * digest-suffixed when long so two long ids never share a prefix-only spelling. */ -export function providerTimelineKeyPart(value: string): string { - const encoded = encodeURIComponent(value) - if (Buffer.byteLength(encoded, 'utf8') <= MAX_KEY_PART_BYTES) { - return encoded - } - const suffix = `#${digestPayload(value).slice(0, 24)}` - const head = boundPayload(encoded, { - inlineHeadBytes: MAX_KEY_PART_BYTES - Buffer.byteLength(suffix, 'utf8') - }).head - return `${head}${suffix}` -} - -/** A key as one bounded string inside its namespace (and thread, for a per-thread key). */ -export function spellProviderTimelineKey( - namespace: string, - key: ProviderTimelineKey, - thread: string | null = null -): string { - // A provider's item ids are its own per thread, so a subagent thread's ids are spelled apart. - const scope = thread === null ? '' : `${providerTimelineKeyPart(thread)}/` - return key.source === 'provider' - ? `p:${providerTimelineKeyPart(namespace)}:${scope}${providerTimelineKeyPart(key.value)}` - : `m:${providerTimelineKeyPart(key.value)}` -} - -/** - * The scheme for a provider with no identity arm of its own: the existing `legacy` arm, so no row - * shape changes. Turn rows keep the `turn-lifecycle:` record prefix the other lanes write. - * Provider keys are spelled inside their namespace, and apart from minted ones. - */ -export function createLegacyProviderTimelineIdentityScheme(input: { - agent: AgentType - sessionId: string -}): ProviderTimelineIdentityScheme { - const identity = (recordId: string): AgentJournalItemIdentity => ({ - provider: 'legacy', - agent: input.agent, - sessionId: input.sessionId, - recordId - }) - return { - ordinalMessages: false, - turn: (address) => - identity(`turn-lifecycle:${spellProviderTimelineKey(address.namespace, address.key)}`), - turnId: (address) => spellProviderTimelineKey(address.namespace, address.key), - item: (address) => - identity( - `${address.family}:${spellProviderTimelineKey(address.namespace, address.key, address.family === 'request' ? null : address.thread)}${ - address.incarnation > 1 ? `#${address.incarnation}` : '' - }` - ) - } -} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts deleted file mode 100644 index 6a3f1e8d9d3f..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalApprovalItem, - type AgentJournalItemIdentity -} from '../../../shared/agent-session-journal-types' -import { createCodexProviderTimelineIdentityScheme } from '../../codex/codex-provider-timeline-identity' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createLegacyProviderTimelineIdentityScheme } from './provider-timeline-identity' -import { ProviderTimelineJoins, type ProviderTimelineItemJoin } from './provider-timeline-joins' - -const journals = createTrackedJournalOpener() -const roots: string[] = [] - -afterEach(async () => { - await journals.closeAll() - for (const root of roots.splice(0)) { - await rm(root, { recursive: true, force: true }) - } -}) - -async function openJournal(): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-provider-timeline-joins-')) - roots.push(root) - return journals.open({ - identity: { - sessionId: 'session-joins', - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'root' } - }, - stateDirectory: root, - now: () => 1_000 - }) -} - -const codexJoins = () => - new ProviderTimelineJoins({ - scheme: createCodexProviderTimelineIdentityScheme({ - sessionId: 'session-joins', - primaryThreadId: () => 'root' - }), - generation: 'gen-1', - namespace: 'ns' - }) - -const message = (id: string): ProviderTimelineItemJoin => ({ - family: 'item', - key: { source: 'provider', value: id }, - thread: 'root' -}) - -const inTurn = (turn: string) => ({ - thread: 'root', - turn: { source: 'provider', value: turn } as const, - scope: { kind: 'turn', turnItemId: `turn:${turn}` } as const -}) - -const codexSlot = (turn: string, ordinal: number, threadId = 'root'): AgentJournalItemIdentity => ({ - provider: 'codex', - threadId, - turnId: turn, - ordinal -}) - -async function write( - journal: AgentSessionJournal, - row: { identity: AgentJournalItemIdentity; ref?: string }, - text: string -) { - await journal.appendItem( - row.identity, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] }, - { - fence: 1, - turnScope: { kind: 'turn', turnItemId: 'turn:t1' }, - ...(row.ref === undefined ? {} : { providerItemRef: row.ref }) - } - ) -} - -describe('provider timeline joins', () => { - it('finds an ordinal message again from the reference its row carries, after a restart', async () => { - const journal = await openJournal() - const before = codexJoins() - const m0 = before.place(message('m0'), 'message', inTurn('t1'), journal) - await write(journal, m0, 'first') - const m1 = before.place(message('m1'), 'message', inTurn('t1'), journal) - await write(journal, m1, 'second') - expect([m0.itemId, m1.itemId]).toEqual(['codex:root:t1:0', 'codex:root:t1:1']) - - const after = codexJoins() - expect(after.find(message('m1'), journal)).toMatchObject({ - itemId: 'codex:root:t1:1', - scope: { kind: 'turn', turnItemId: 'turn:t1' } - }) - // A new message takes the next free place, past every row the turn holds. - expect(after.place(message('m2'), 'message', inTurn('t1'), journal).itemId).toBe( - 'codex:root:t1:2' - ) - }) - - it('skips a place an echoed send holds through its submission alias', async () => { - const journal = await openJournal() - await journal.appendSubmission({ - clientMessageId: 'send-1', - payloadFingerprint: 'fp', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'send-1', - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: 'root', turnId: 't1', ordinal: 0 }, - fence: 1 - }) - expect(codexJoins().place(message('reply'), 'message', inTurn('t1'), journal).itemId).toBe( - 'codex:root:t1:1' - ) - }) - - it('continues past the highest ordinal the journal holds, never into a gap below it', async () => { - const journal = await openJournal() - await write(journal, { identity: codexSlot('t1', 2), ref: 'item:old' }, 'old') - // Another turn and another thread keep their own places. - await write(journal, { identity: codexSlot('t2', 7) }, 'other turn') - await write(journal, { identity: codexSlot('t1', 9, 'sub') }, 'subagent') - const joins = codexJoins() - expect(joins.place(message('new'), 'message', inTurn('t1'), journal).itemId).toBe( - 'codex:root:t1:3' - ) - expect(joins.place(message('next'), 'message', inTurn('t1'), journal).itemId).toBe( - 'codex:root:t1:4' - ) - }) - - it('continues past an echoed send above the highest row', async () => { - const journal = await openJournal() - await write(journal, { identity: codexSlot('t1', 0) }, 'first') - await journal.appendSubmission({ - clientMessageId: 'send-1', - payloadFingerprint: 'fp', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'send-1', - state: 'accepted', - providerIdentity: codexSlot('t1', 4), - fence: 1 - }) - expect(codexJoins().place(message('reply'), 'message', inTurn('t1'), journal).itemId).toBe( - 'codex:root:t1:5' - ) - }) - - it('forgets what it read from an epoch the journal replaced', async () => { - const journal = await openJournal() - const joins = codexJoins() - const m0 = joins.place(message('m0'), 'message', inTurn('t1'), journal) - await write(journal, m0, 'first') - expect(joins.find(message('m0'), journal)?.itemId).toBe('codex:root:t1:0') - await journal.replaceEpochItems('handle_forked', 1, []) - expect(joins.find(message('m0'), journal)).toBeNull() - expect(joins.place(message('m1'), 'message', inTurn('t1'), journal).itemId).toBe( - 'codex:root:t1:0' - ) - }) - - it('finds a row whose identity spells its key without any reference', async () => { - const journal = await openJournal() - const scheme = createLegacyProviderTimelineIdentityScheme({ - agent: 'grok', - sessionId: 'session-joins' - }) - const joins = () => new ProviderTimelineJoins({ scheme, generation: 'g', namespace: 'ns' }) - const tool = joins().place(message('tool'), 'tool-call', inTurn('t1'), journal) - expect(tool.ref).toBeUndefined() - await write(journal, tool, 'x') - expect(joins().find(message('tool'), journal)?.itemId).toBe(tool.itemId) - expect(joins().find({ ...message('tool'), thread: 'other' }, journal)).toBeNull() - }) - - it('reads the current request incarnation back from the rows', async () => { - const journal = await openJournal() - const scheme = createLegacyProviderTimelineIdentityScheme({ - agent: 'grok', - sessionId: 'session-joins' - }) - const joins = () => new ProviderTimelineJoins({ scheme, generation: 'g', namespace: 'ns' }) - const key = { source: 'provider', value: 'p1' } as const - const thread = { thread: null, turn: null, scope: AGENT_JOURNAL_THREAD_SCOPE } - const approval: AgentJournalApprovalItem = { - kind: 'approval', - title: 'Run?', - detail: null, - options: [], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - } - const before = joins() - for (let incarnation = 1; incarnation <= 2; incarnation += 1) { - const row = before.nextRequest(key, 'approval', thread, journal) - expect(row.incarnation).toBe(incarnation) - await journal.appendItem(row.identity, approval, { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - } - expect(joins().request(key, journal)).toMatchObject({ - incarnation: 2, - itemId: agentJournalItemKey( - scheme.item({ - namespace: 'ns', - family: 'request', - key, - thread: null, - turn: null, - itemClass: 'approval', - messageOrdinal: null, - incarnation: 2 - }) - ) - }) - }) -}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts deleted file mode 100644 index 7cb48073933d..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts +++ /dev/null @@ -1,306 +0,0 @@ -// Provider joins → journal rows: the assembler's canonical join index. -// -// Every row the assembler writes is found again through here, and every entry is a cache of a -// fact the journal holds. A row whose identity spells its provider key is found by recomputing -// that identity; a row whose identity does not (a message keyed by its place among its turn's -// messages) carries the key as `providerItemRef`, which the journal indexes. So a miss — after a -// restart or an eviction — recovers the original row; it never re-places it. Message ordinals and -// request incarnations are read back from the rows that hold them. Only transition resolvers call -// the allocating members, so allocation happens in journal order and a refused event allocates -// nothing. - -import { - agentJournalItemKey, - parseAgentJournalItemKey -} from '../../../shared/agent-session-journal-item-key' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemIdentity, - type AgentJournalRenderItem, - type AgentJournalTurnScope -} from '../../../shared/agent-session-journal-types' -import { BoundedMap } from '../../../shared/bounded-map' -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import { - spellProviderTimelineKey, - type ProviderTimelineIdentityScheme, - type ProviderTimelineItemAddress, - type ProviderTimelineItemClass, - type ProviderTimelineItemFamily, - type ProviderTimelineKey, - type ProviderTimelineTurnAddress -} from './provider-timeline-identity' -import { ProviderTimelineMessagePlaces } from './provider-timeline-message-places' - -const MAX_JOINED_ROWS = 1_024 -const MAX_JOINED_ROW_BYTES = 1024 * 1024 - -/** One provider item as the provider names it. Item keys are the provider's per thread. */ -export type ProviderTimelineItemJoin = { - family: ProviderTimelineItemFamily - key: ProviderTimelineKey - thread: string | null -} - -export type ProviderTimelineRow = { - identity: AgentJournalItemIdentity - itemId: string - scope: AgentJournalTurnScope - /** The join's reference, on a row whose identity does not spell its key. */ - ref?: string - /** Which request under its key, 1 for the first. */ - incarnation: number -} - -export type ProviderTimelineTurnRef = { - address: ProviderTimelineTurnAddress - identity: AgentJournalItemIdentity - itemId: string - turnId: string -} - -/** Where a new row goes: the provider thread and turn, and the journal turn scope. */ -export type ProviderTimelinePlacement = { - thread: string | null - turn: ProviderTimelineKey | null - scope: AgentJournalTurnScope -} - -type Journal = StructuredAgentSessionTransitionJournal | null - -function bytes(value: string): number { - return Buffer.byteLength(value, 'utf8') -} - -export class ProviderTimelineJoins { - private readonly rows = new BoundedMap({ - maxEntries: MAX_JOINED_ROWS, - maxBytes: MAX_JOINED_ROW_BYTES, - sizeOf: (row, key) => bytes(key) + 2 * bytes(row.itemId) + 64 - }) - private readonly places: ProviderTimelineMessagePlaces - /** The journal epoch every cache entry was read from; a replaced epoch holds other rows. */ - private epoch: string | null = null - private serial = 0 - - constructor( - private readonly deps: { - scheme: ProviderTimelineIdentityScheme - generation: string - namespace: string - } - ) { - this.places = new ProviderTimelineMessagePlaces({ - scheme: deps.scheme, - namespace: () => this.deps.namespace - }) - } - - get namespace(): string { - return this.deps.namespace - } - - /** A new provider session: its ids name new rows. Minted keys stay unique as they are. */ - reset(namespace: string): void { - this.deps.namespace = namespace - this.rows.clear() - this.places.clear() - } - - /** Drops every cache read from an epoch the journal has since replaced (a rewind, an import). */ - private sync(journal: Journal): void { - if (!journal || journal.epoch === this.epoch) { - return - } - if (this.epoch !== null) { - this.rows.clear() - this.places.clear() - } - this.epoch = journal.epoch - } - - /** A key unique to this acquisition, for something the provider names nothing. */ - mint(kind: string): ProviderTimelineKey { - this.serial += 1 - return { source: 'minted', value: `${this.deps.generation}:${kind}${this.serial}` } - } - - /** A turn's row. `namespace` defaults to the current one; a planner names the one it expects. */ - turn(key: ProviderTimelineKey, namespace = this.deps.namespace): ProviderTimelineTurnRef { - const address = { namespace, key } - const identity = this.deps.scheme.turn(address) - return { - address, - identity, - itemId: agentJournalItemKey(identity), - turnId: this.deps.scheme.turnId(address) - } - } - - /** The user item a turn names when no send of Orca's opened it. */ - turnOpener(turn: ProviderTimelineTurnRef): string { - return this.deps.scheme.turnOpener?.(turn.address) ?? turn.itemId - } - - /** The join as one bounded string: what rows carry as `providerItemRef` and caches key by. */ - reference(join: ProviderTimelineItemJoin, namespace = this.deps.namespace): string { - const thread = join.family === 'request' ? null : join.thread - return `${join.family}:${spellProviderTimelineKey(namespace, join.key, thread)}` - } - - /** The row the join already names, from memory or the fold; null when the journal holds none. */ - find( - join: ProviderTimelineItemJoin, - journal: Journal, - namespace = this.deps.namespace - ): ProviderTimelineRow | null { - this.sync(journal) - const ref = this.reference(join, namespace) - const cached = this.rows.get(ref) - // A planner ahead of a reset asks about a namespace whose rows this index does not spell yet. - if (cached || !journal || namespace !== this.deps.namespace) { - return cached ?? null - } - const itemId = - journal.itemIdForProviderItemRef(ref) ?? agentJournalItemKey(this.spelled(join, 1)) - const row = rowOf(journal.item(itemId), 1) - if (row) { - this.rows.set(ref, row) - } - return row - } - - /** A new row for a join no row names yet. Allocates its message ordinal: resolvers only. */ - place( - join: ProviderTimelineItemJoin, - itemClass: ProviderTimelineItemClass, - placement: ProviderTimelinePlacement, - journal: Journal - ): ProviderTimelineRow { - this.sync(journal) - const ordinal = - this.deps.scheme.ordinalMessages && itemClass === 'message' && placement.turn - ? this.places.next(placement.thread, placement.turn, journal) - : null - const identity = this.deps.scheme.item({ - ...this.address(join, 1), - turn: placement.turn, - itemClass, - messageOrdinal: ordinal - }) - const ref = this.reference(join) - const row: ProviderTimelineRow = { - identity, - itemId: agentJournalItemKey(identity), - scope: placement.scope, - ...(ordinal === null ? {} : { ref }), - incarnation: 1 - } - this.rows.set(ref, row) - return row - } - - /** An echoed send takes its message's place without a row (its send is the bubble). */ - reserveEcho( - join: ProviderTimelineItemJoin, - placement: ProviderTimelinePlacement, - journal: Journal - ) { - if (!this.deps.scheme.ordinalMessages || !placement.turn || this.find(join, journal)) { - return - } - const row = this.place(join, 'message', placement, journal) - this.places.reserve(row.itemId) - } - - /** The request under `key` the journal holds last: the highest incarnation with a row. */ - request( - key: ProviderTimelineKey, - journal: Journal, - namespace = this.deps.namespace - ): ProviderTimelineRow | null { - this.sync(journal) - const join = { family: 'request' as const, key, thread: null } - const ref = this.reference(join, namespace) - const cached = this.rows.get(ref) - if (cached || !journal || namespace !== this.deps.namespace) { - return cached ?? null - } - let current: ProviderTimelineRow | null = null - for (let incarnation = 1; ; incarnation += 1) { - const itemId = agentJournalItemKey(this.spelled(join, incarnation)) - // A scheme that spells no incarnation names one row for every request under the key. - const row = itemId === current?.itemId ? null : rowOf(journal.item(itemId), incarnation) - if (!row) { - break - } - current = row - } - if (current) { - this.rows.set(ref, current) - } - return current - } - - /** The next request under `key`: resolvers only. */ - nextRequest( - key: ProviderTimelineKey, - itemClass: ProviderTimelineItemClass, - placement: ProviderTimelinePlacement, - journal: Journal - ): ProviderTimelineRow { - const join = { family: 'request' as const, key, thread: null } - const incarnation = (this.request(key, journal)?.incarnation ?? 0) + 1 - const identity = this.deps.scheme.item({ - ...this.address(join, incarnation), - thread: placement.thread, - turn: placement.turn, - itemClass - }) - const row = { - identity, - itemId: agentJournalItemKey(identity), - scope: placement.scope, - incarnation - } - this.rows.set(this.reference(join), row) - return row - } - - private address( - join: ProviderTimelineItemJoin, - incarnation: number - ): ProviderTimelineItemAddress { - return { - namespace: this.deps.namespace, - family: join.family, - key: join.key, - thread: join.thread, - turn: null, - itemClass: 'status', - messageOrdinal: null, - incarnation - } - } - - private spelled(join: ProviderTimelineItemJoin, incarnation: number): AgentJournalItemIdentity { - return this.deps.scheme.item(this.address(join, incarnation)) - } -} - -function rowOf( - item: AgentJournalRenderItem | null, - incarnation: number -): ProviderTimelineRow | null { - const identity = item ? parseAgentJournalItemKey(item.itemId) : null - if (!item || !identity) { - return null - } - return { - identity, - itemId: item.itemId, - scope: item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE, - ...(item.providerItemRef === undefined ? {} : { ref: item.providerItemRef }), - incarnation - } -} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts deleted file mode 100644 index e5350c39c93a..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Where a new message goes among its (thread, turn)'s messages, for a scheme that keys messages -// by that place. Read back from the journal on a miss, past every place a row or an echoed send -// holds, so a restart continues the sequence the journal already has. - -import { - agentJournalItemKey, - parseAgentJournalItemKey -} from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' -import { BoundedMap } from '../../../shared/bounded-map' -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import { - providerTimelineKeyPart, - spellProviderTimelineKey, - type ProviderTimelineIdentityScheme, - type ProviderTimelineKey -} from './provider-timeline-identity' - -const MAX_TURN_ORDINALS = 256 -/** Echoed sends whose slot no row holds yet; a lane records each on its send soon after. */ -const MAX_RESERVED_ECHOES = 64 - -type Journal = StructuredAgentSessionTransitionJournal | null - -function bytes(value: string): number { - return Buffer.byteLength(value, 'utf8') -} - -export class ProviderTimelineMessagePlaces { - /** The next ordinal per place. */ - private readonly ordinals = new BoundedMap({ - maxEntries: MAX_TURN_ORDINALS, - maxBytes: MAX_TURN_ORDINALS * 1024, - sizeOf: (_next, place) => bytes(place) + 8 - }) - private readonly echoes = new BoundedMap({ - maxEntries: MAX_RESERVED_ECHOES, - maxBytes: MAX_RESERVED_ECHOES * 1024, - sizeOf: (_held, itemId) => bytes(itemId) + 8 - }) - - constructor( - private readonly deps: { - scheme: ProviderTimelineIdentityScheme - namespace: () => string - } - ) {} - - clear(): void { - this.ordinals.clear() - this.echoes.clear() - } - - /** Holds a place no row takes (an echoed send's). */ - reserve(itemId: string): void { - this.echoes.set(itemId, true) - } - - /** The next place in (thread, turn): allocating, so resolvers only. */ - next(thread: string | null, turn: ProviderTimelineKey, journal: Journal): number { - const place = `${providerTimelineKeyPart(thread ?? '')}\u001f${spellProviderTimelineKey( - this.deps.namespace(), - turn - )}` - let ordinal = this.ordinals.get(place) ?? this.highWater(thread, turn, journal) - // Another writer of the same turn may have taken places since this one cached its mark. - while (this.taken(agentJournalItemKey(this.slot(thread, turn, ordinal)), journal)) { - ordinal += 1 - } - this.ordinals.set(place, ordinal + 1) - return ordinal - } - - /** One past the highest ordinal the journal holds in the place, like the ordinal counter it - * continues: a gap below it (a removed or never-imported row) is never filled. */ - private highWater(thread: string | null, turn: ProviderTimelineKey, journal: Journal): number { - const read = this.deps.scheme.messageSlot - const place = read?.(this.slot(thread, turn, 0))?.place - if (!read || place === undefined || !journal) { - return 0 - } - let next = 0 - const consider = (itemId: string | null) => { - const identity = itemId === null ? null : parseAgentJournalItemKey(itemId) - const slot = identity ? read(identity) : null - if (slot?.place === place) { - next = Math.max(next, slot.ordinal + 1) - } - } - journal.visitItems((itemId) => consider(itemId)) - // An echoed send holds its place through its submission alias, with no row of its own. - for (const submission of journal.submissions()) { - consider(submission.providerItemId) - } - return next - } - - private slot( - thread: string | null, - turn: ProviderTimelineKey, - ordinal: number - ): AgentJournalItemIdentity { - return this.deps.scheme.item({ - namespace: this.deps.namespace(), - family: 'item', - key: turn, - thread, - turn, - itemClass: 'message', - messageOrdinal: ordinal, - incarnation: 1 - }) - } - - private taken(itemId: string, journal: Journal): boolean { - return ( - this.echoes.has(itemId) || - (journal !== null && - (journal.itemBody(itemId) !== null || journal.canonicalItemId(itemId) !== itemId)) - ) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 5cdabdce713d..8e997bf8477f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -51,8 +51,6 @@ export type StructuredAgentSessionPublishOptions = Pick< /** An item write states which turn its row belongs to; the write that creates the row decides. */ export type StructuredAgentSessionItemAppendOptions = StructuredAgentSessionAppendOptions & { turnScope: AgentJournalTurnScope - /** The provider's reference for the item, on the write that creates its row. */ - providerItemRef?: string } export type StructuredAgentSessionLifecycleJournal = Pick< diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts index 57bb482f7c2b..e7ce667ed2b6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts @@ -16,7 +16,6 @@ export function structuredAgentSessionJournalAppendOptions( fence, ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }), turnScope: options.turnScope, - ...(options.providerItemRef === undefined ? {} : { providerItemRef: options.providerItemRef }), ...agentJournalLinkageFields(options) } } diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 271053b1b214..e5f8125a5c0f 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -360,16 +360,8 @@ export const AGENT_JOURNAL_THREAD_SCOPE: AgentJournalTurnScope = { kind: 'thread /** Who produced a row and which turn it belongs to: what every item write states. */ export type AgentJournalRowAttribution = AgentJournalProducerLinkage & { turnScope: AgentJournalTurnScope - providerItemRef?: AgentJournalProviderItemRef } -/** The provider's own reference for the item a row is, as its producer joined it, on a row whose - * identity does not spell it (a Codex message keys by its turn ordinal, and Codex renumbers its - * item ids on resume). Set by the write that creates the row and kept by every revision, like the - * turn scope. Only its producer reads it back, to find the row again after a restart; no client - * depends on it. */ -export type AgentJournalProviderItemRef = string - /** Where the journal placed an item: the sequence of the row that created it, * then its place among that row's writes. The timeline's only ordering key. */ export type AgentJournalPosition = { @@ -395,7 +387,6 @@ export type AgentJournalRenderItem = AgentJournalProducerLinkage & { recoveredAt?: number /** Absent only from a host that predates it. */ turnScope?: AgentJournalTurnScope - providerItemRef?: AgentJournalProviderItemRef } // ─── Submissions ──────────────────────────────────────────────────────────── diff --git a/tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts deleted file mode 100644 index ff0ecc5fed33..000000000000 --- a/tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { expect, test } from 'vitest' -import type { AgentJournalItemIdentity } from '../../../src/shared/agent-session-journal-types' -import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' -import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' -import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' - -// A release that predates `providerItemRef` on journal rows and render items. -const BASELINE_REF = 'v1.4.218' - -const SESSION = 'session-provider-item-ref' - -function message(ordinal: number): AgentJournalItemIdentity { - return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } -} - -function releaseExport(module: Record, name: string): T { - const value = module[name] - if (value === undefined) { - throw new Error(`the pinned release exports no ${name}`) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an export the pinned release has; each caller names the shape it uses, and a changed one fails the test. - return value as T -} - -type OldRenderItem = { itemId: string; [field: string]: unknown } - -// Loads a real old build, including cold extraction and transforms. -test('an older host and client render a row carrying a provider item reference as if it had none', async () => { - const directory = mkdtempSync(join(tmpdir(), 'orca-provider-item-ref-downgrade-')) - const journals = createTrackedJournalOpener() - try { - // This build: the same message twice, the first carrying the reference. - const journal = await journals.open({ - identity: { - sessionId: SESSION, - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } - }, - stateDirectory: directory, - now: () => 1_000 - }) - const body = { - kind: 'message' as const, - role: 'assistant' as const, - blocks: [{ type: 'text' as const, text: 'Hello' }] - } - const scope = { kind: 'thread' as const } - await journal.appendItem(message(0), body, { - fence: 1, - turnScope: scope, - providerItemRef: 'item:p:thread-1/item-1' - }) - await journal.appendItem(message(1), body, { fence: 1, turnScope: scope }) - const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) - if (!since.ok) { - throw new Error(`expected rows, got reset ${since.reset}`) - } - const rows: JournalRow[] = since.rows - const current = journal.snapshot().items - expect(current[0]?.providerItemRef).toBe('item:p:thread-1/item-1') - - const checkout = await materializeReleaseCheckout(BASELINE_REF) - const [reducer, schemas, projection] = await Promise.all( - [ - 'src/main/native-chat/agent-session-journal/journal-reducer.ts', - 'src/shared/agent-session-journal-schemas.ts', - 'src/shared/structured-agent-session-projection.ts' - ].map((path) => importReleaseCheckoutModule(checkout, path)) - ) - const createState = releaseExport<(sessionId: string, epoch: string) => unknown>( - reducer, - 'createJournalReducerState' - ) - const applyRow = releaseExport<(state: unknown, row: JournalRow) => void>( - reducer, - 'applyJournalRow' - ) - const render = releaseExport<(state: unknown) => { items: OldRenderItem[] }>( - reducer, - 'renderJournalState' - ) - const renderItemSchema = releaseExport<{ parse: (value: unknown) => OldRenderItem }>( - schemas, - 'AgentJournalRenderItemSchema' - ) - const project = releaseExport<(item: unknown) => unknown>( - projection, - 'projectStructuredItemToNativeChat' - ) - - // The older host, after a downgrade, folds the rows as it always did. - const state = createState(SESSION, journal.epoch) - for (const row of rows) { - applyRow(state, row) - } - const folded = render(state).items - expect(folded.map((item) => item.itemId)).toEqual([ - 'codex:thread-1:turn-1:0', - 'codex:thread-1:turn-1:1' - ]) - const [first, second] = folded - if (!first || !second) { - throw new Error('the older host folded both rows') - } - expect(first).not.toHaveProperty('providerItemRef') - const same = ({ itemId: _id, sequence: _seq, ...rest }: OldRenderItem) => rest - expect(same(first)).toEqual(same(second)) - - // The older client reads this host's render item without the field, and renders it the same. - const [withRef, withoutRef] = current.map((item) => renderItemSchema.parse(item)) - if (!withRef || !withoutRef) { - throw new Error('the older client read both items') - } - expect(withRef).not.toHaveProperty('providerItemRef') - expect(project(withRef)).toEqual( - project({ ...withoutRef, itemId: withRef.itemId, sequence: withRef.sequence }) - ) - } finally { - await journals.closeAll() - rmSync(directory, { recursive: true, force: true }) - } -}) From 7d048776c85f0ef254cd9f674e1c51844e0c0103 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:13:40 -0700 Subject: [PATCH 117/269] Write a resolved settlement in one transaction through enqueueRows A settlement too large for one row now commits all its rows or none, through the journal's existing all-or-nothing write, instead of a row-by-row writer. Every row is built before any commits, so a settlement naming one item twice is refused before anything is written. --- .../journal-lifecycle-batch-appender.ts | 37 ++++++++--- .../journal-row-writer.ts | 61 +++++++------------ .../journal-store-collaborators.ts | 1 - 3 files changed, 52 insertions(+), 47 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts index 9f1e0cce218f..cc300a7b7497 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts @@ -1,13 +1,16 @@ +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { JournalReducerState } from './journal-reducer' import { partitionJournalLifecycleMutations } from './journal-lifecycle-batch-partition' -import { journalLifecycleBatchRowBuilder } from './journal-row-builders' +import { + journalLifecycleBatchRowBuilder, + type JournalLifecycleMutationInput +} from './journal-row-builders' import type { JournalLifecycleBatchInput, JournalResolvedLifecycleBatchInput } from './journal-store-contracts' import type { JournalRow } from './journal-row-schema' -import type { JournalRowWriter } from './journal-row-writer' import { journalQueuedRejectionRowBuilders } from './journal-pending-submission-recovery' const SETTLEMENT_ALREADY_APPLIED = new Error('journal_settlement_already_applied') @@ -18,8 +21,9 @@ export class JournalLifecycleBatchAppender { state: () => JournalReducerState cursor: () => AgentJournalCursor enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise - enqueueEach: JournalRowWriter['enqueueEach'] - enqueueRows: JournalRowWriter['enqueueRows'] + enqueueRows: ( + plan: () => readonly ((seq: number, ts: number) => JournalRow)[] + ) => Promise } ) {} @@ -75,11 +79,15 @@ export class JournalLifecycleBatchAppender { } /** Chooses the mutations at its turn in the queue; one too large for a row becomes consecutive - * rows that nothing else lands between, in the order resolved. */ + * rows, committed in one transaction, in the order resolved. */ appendResolved(input: JournalResolvedLifecycleBatchInput): Promise { return this.deps - .enqueueEach(() => - partitionJournalLifecycleMutations(input.settlementId, input.resolve()) + .enqueueRows(() => { + const mutations = input.resolve() + // Every chunk is built before any commits, so a second chunk naming the same item would + // reuse the first chunk's revision. + this.assertDistinctItems(mutations) + return partitionJournalLifecycleMutations(input.settlementId, mutations) .filter((chunk) => !this.wasApplied(chunk.settlementId)) .map((chunk) => journalLifecycleBatchRowBuilder( @@ -89,13 +97,26 @@ export class JournalLifecycleBatchAppender { input ) ) - ) + }) .then((rows) => { const last = rows.at(-1) return last ? { epoch: last.epoch, sequence: last.seq } : null }) } + private assertDistinctItems(mutations: readonly JournalLifecycleMutationInput[]): void { + const { aliases } = this.deps.state() + const seen = new Set() + for (const mutation of mutations) { + const itemId = agentJournalItemKey(mutation.identity) + const resolved = aliases.get(itemId) ?? itemId + if (seen.has(resolved)) { + throw new Error('journal_resolved_lifecycle_batch_names_item_twice') + } + seen.add(resolved) + } + } + private wasApplied(settlementId: string): boolean { return this.deps.state().appliedSettlementIds.has(settlementId) } diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index 312635c60a77..dde1a66741d1 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -46,44 +46,29 @@ export class JournalRowWriter { hook?: JournalRowTransactionHook, receipt?: JournalOperationReceipt ): Promise { - return this.deps.serialize(() => this.write(build, hook, receipt)) - } - - /** Rows chosen at this write's turn in the queue and written back to back, so no other write - * lands between them; each commits alone and is built after the one before it is adopted, so a - * later row may revise an earlier one. `enqueueRows` is the all-or-nothing form. */ - enqueueEach( - resolve: () => readonly ((seq: number, ts: number) => JournalRow)[] - ): Promise { - return this.deps.serialize(() => resolve().map((build) => this.write(build))) - } - - private write( - build: (seq: number, ts: number) => JournalRow, - hook?: JournalRowTransactionHook, - receipt?: JournalOperationReceipt - ): JournalRow { - assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) - const row = build(this.deps.nextSequence(), this.deps.now()) - assertJournalFence(row.fence, this.deps.highestFence()) - try { - // One INSERT: the chat's epoch pointer moves only when the epoch does. - this.deps.database().transaction((db) => { - insertJournalRow(db, this.deps.sessionId, row) - hook?.(db, row) - receipt?.write(db) - this.runBookkeeping(db, row) - }) - } catch (error) { - this.deps.rolledBack?.() - throw error - } - // COMMIT landed, so the row is durable: adopt it before anything that can - // fail. Rejecting here instead would leave the next append reusing a - // sequence the table already holds. The ledger first: it cannot throw, the fold can. - receipt?.committed() - this.deps.commit(row) - return row + return this.deps.serialize(() => { + assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) + const row = build(this.deps.nextSequence(), this.deps.now()) + assertJournalFence(row.fence, this.deps.highestFence()) + try { + // One INSERT: the chat's epoch pointer moves only when the epoch does. + this.deps.database().transaction((db) => { + insertJournalRow(db, this.deps.sessionId, row) + hook?.(db, row) + receipt?.write(db) + this.runBookkeeping(db, row) + }) + } catch (error) { + this.deps.rolledBack?.() + throw error + } + // COMMIT landed, so the row is durable: adopt it before anything that can + // fail. Rejecting here instead would leave the next append reusing a + // sequence the table already holds. The ledger first: it cannot throw, the fold can. + receipt?.committed() + this.deps.commit(row) + return row + }) } /** Several rows in ONE transaction, in order, planned once the lane is this append's: none is diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 6a77b8001032..ba8796964692 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -125,7 +125,6 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal state: host.state, cursor: host.cursor, enqueue: host.enqueue, - enqueueEach: (resolve) => rowWriter.enqueueEach(resolve), enqueueRows: (plan) => rowWriter.enqueueRows(plan) }) } From e1c3ccdaa3c93c99c08d95764b979948f3d162b0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:13:40 -0700 Subject: [PATCH 118/269] Drop the transition's landing report; keep the turn-row write fire-and-forget Nothing reads which steps of a transition wrote. A failed step fails the sink, leaving the steps before it written; the header says so, and tests cover it plus a settlement whose second row fails inside the transaction. writeAgentJournalTurnRow returns nothing again, as on main. --- .../agent-journal-turn-row-revision.ts | 9 +- ...tructured-agent-session-transition.test.ts | 119 +++++++++++++++--- .../structured-agent-session-transition.ts | 24 +--- 3 files changed, 108 insertions(+), 44 deletions(-) diff --git a/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts b/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts index 69356c406784..dfede8c46f7c 100644 --- a/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts +++ b/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts @@ -24,8 +24,7 @@ import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/s import type { StructuredAgentSessionEventSink, StructuredAgentSessionRevisionJournal, - StructuredAgentSessionRevisionOptions, - StructuredAgentSessionSinkAdmission + StructuredAgentSessionRevisionOptions } from '../agent-session-wire/structured-agent-session-event-sink' /** The row a write revises: a known one, or the newest turn in the journal. */ @@ -203,7 +202,7 @@ export function writeAgentJournalTurnRow( target: AgentJournalTurnRowTarget, write: AgentJournalTurnRowWrite, { publish, options: delivery = {} }: AgentJournalTurnRowDelivery -): StructuredAgentSessionSinkAdmission { +): void { // A turn record belongs to no turn. const options = { ...delivery, turnScope: AGENT_JOURNAL_THREAD_SCOPE } const revise = publish ? sink.tryReviseResolvedItemAndPublish : sink.tryReviseResolvedItem @@ -214,10 +213,10 @@ export function writeAgentJournalTurnRow( sink.publish() } } - return { accepted: true } + return } const reservedBytes = agentJournalTurnRowReservedBytes(target, write) - return revise.call( + revise.call( sink, reservedBytes, (journal) => resolveAgentJournalTurnRowWrite(journal, target, write, reservedBytes), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts index 1b242dd8d983..f752a2147afc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -8,7 +8,11 @@ import { type AgentJournalItemIdentity, type AgentJournalToolCallItem } from '../../../shared/agent-session-journal-types' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createAgentSessionDeltaCoalescer } from './agent-session-delta-coalescer' import { @@ -38,6 +42,15 @@ function tool(name: string, state: AgentJournalToolCallItem['state']): AgentJour return { kind: 'tool-call', name, input: { name }, state } } +function failedTool(id: AgentJournalItemIdentity): JournalLifecycleMutationInput { + return { + kind: 'item', + identity: id, + body: tool('read', 'failed'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } +} + function itemStep( resolve: Extract['resolve'] ): StructuredAgentSessionTransitionStep { @@ -69,7 +82,7 @@ async function rig(watermarks: Partial = { watermarks }) const bind = () => deferred.bind({ journal, fence: 1, publish: () => publishes.push(1) }) - return { journal, deferred, sink: deferred.sink, publishes, bind } + return { root, journal, deferred, sink: deferred.sink, publishes, bind } } describe('structured agent-session transitions', () => { @@ -108,10 +121,9 @@ describe('structured agent-session transitions', () => { expect(publishes).toHaveLength(1) }) - it('places a row where its resolver says, and hears every landing', async () => { + it('places a row where its resolver says', async () => { const { journal, deferred, sink, bind } = await rig() bind() - const landed: (readonly boolean[])[] = [] const scope = { kind: 'turn', turnItemId: 'turn-row' } as const sink.tryAppendTransition?.({ lifecycle: false, @@ -120,19 +132,44 @@ describe('structured agent-session transitions', () => { itemStep(() => ({ identity: identity('placed'), body: tool('read', 'running'), - options: { turnScope: scope, providerItemRef: 'item:read' } + options: { turnScope: scope } })), itemStep(() => null) - ], - landed: (wrote) => landed.push(wrote) + ] }) await deferred.drained() expect(journal.item(agentJournalItemKey(identity('placed')))).toMatchObject({ - turnScope: scope, - providerItemRef: 'item:read' + turnScope: scope }) - expect(landed).toEqual([[true, false]]) + }) + + it('admitted whole is not executed whole: a failed step keeps the ones before it and fails the sink', async () => { + const { journal, deferred, sink, publishes, bind } = await rig() + bind() + sink.tryAppendTransition?.({ + lifecycle: false, + publish: true, + steps: [ + itemStep(() => ({ identity: identity('kept'), body: tool('read', 'running') })), + itemStep(() => { + throw new Error('resolver failed') + }) + ] + }) + + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalItemKey(identity('kept')) + ]) + expect(publishes).toEqual([]) + expect( + sink.tryAppendTransition?.({ + lifecycle: false, + publish: false, + steps: [itemStep(() => null)] + }) + ).toEqual({ accepted: false, reason: 'failed' }) }) it('refuses a transition whole, so none of its steps ever lands', async () => { @@ -165,14 +202,10 @@ describe('structured agent-session transitions', () => { sink.tryAppendItem?.(identity('done'), tool('read', 'completed'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - let wrote: readonly boolean[] = [] expect( sink.tryAppendTransition?.({ lifecycle: true, publish: true, - landed: (result) => { - wrote = result - }, steps: [ { kind: 'settlement', @@ -213,20 +246,50 @@ describe('structured agent-session transitions', () => { ).toMatchObject({ state: 'completed' }) // Two batch rows, back to back, between the last append before it and the first after it. expect(sequenceOf('after') - sequenceOf('done')).toBe(3) - expect(wrote).toEqual([true]) expect(publishes).toHaveLength(1) }) + it('leaves no row of a settlement durable when a later one of its rows fails to write', async () => { + const { root, journal, deferred, sink, bind } = await rig() + bind() + const running = Array.from({ length: 250 }, (_, index) => identity(`tool-${index}`)) + for (const id of running) { + sink.tryAppendItem?.(id, tool('read', 'running'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + } + await deferred.drained() + const before = journal.cursor().sequence + // The settlement needs two rows; the second one's insert aborts inside the transaction. + openTestJournalHostDatabase(root).db.exec(`CREATE TEMP TRIGGER fail_second_chunk +BEFORE INSERT ON main.journal_rows WHEN NEW.seq = ${before + 2} +BEGIN SELECT RAISE(ABORT, 'second chunk refused'); END`) + sink.tryAppendTransition?.({ + lifecycle: true, + publish: true, + steps: [ + { + kind: 'settlement', + settlementId: 'settle-all', + reservedBytes: 1, + resolve: () => running.map(failedTool) + } + ] + }) + + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + expect(journal.cursor().sequence).toBe(before) + expect( + journal + .snapshot() + .items.filter((item) => item.body.kind === 'tool-call' && item.body.state === 'failed') + ).toEqual([]) + }) + it('writes and announces nothing when a step resolves to nothing', async () => { const { journal, deferred, sink, publishes, bind } = await rig() bind() - let wrote: readonly boolean[] = [] sink.tryAppendTransition?.({ lifecycle: true, publish: true, - landed: (result) => { - wrote = result - }, steps: [ itemStep(() => null), { kind: 'settlement', settlementId: 'none', reservedBytes: 1, resolve: () => [] } @@ -235,11 +298,27 @@ describe('structured agent-session transitions', () => { await deferred.drained() expect(journal.snapshot().items).toEqual([]) - expect(wrote).toEqual([false, false]) expect(publishes).toEqual([]) }) }) +describe('resolved lifecycle batches', () => { + it('refuses, before writing anything, a settlement that names one item twice', async () => { + const { journal } = await rig() + const before = journal.cursor().sequence + const twice = identity('twice') + + await expect( + journal.appendResolvedLifecycleBatch({ + settlementId: 'twice', + fence: 1, + resolve: () => [failedTool(identity('once')), failedTool(twice), failedTool(twice)] + }) + ).rejects.toThrow('journal_resolved_lifecycle_batch_names_item_twice') + expect(journal.cursor().sequence).toBe(before) + }) +}) + describe('coalescer text a caller writes itself', () => { it('reports unwritten streams and owes nothing once the caller marks them written', () => { const emitted: string[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts index c67dab93edef..a185695d5ea5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -6,6 +6,8 @@ // all. At execution its steps are issued in the same tick, so they sit together in the journal's // write queue, and each step resolves against the fold with every earlier write landed — the // steps before it included — so what a step writes is decided by the journal, not by memory. +// Admitted whole is not executed whole: a step that fails leaves the steps before it written, and +// fails the sink, so nothing more is admitted after it. import type { AgentJournalItemBody, @@ -21,18 +23,10 @@ import type { import type { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' import { structuredAgentSessionJournalAppendOptions } from './structured-agent-session-journal-append-options' -/** What a transition step reads: rows by key or provider reference, every row, their turns, and - * the sends whose echo holds a provider item's place. */ +/** What a transition step reads: rows by key, every row, and the turns they joined. */ export type StructuredAgentSessionTransitionJournal = Pick< AgentSessionJournal, - | 'epoch' - | 'submissions' - | 'visitItems' - | 'itemBody' - | 'item' - | 'itemIdForProviderItemRef' - | 'canonicalItemId' - | 'visitItemsWithLinkage' + 'epoch' | 'visitItems' | 'itemBody' | 'item' | 'visitItemsWithLinkage' > export type StructuredAgentSessionTransitionStep = @@ -69,8 +63,6 @@ export type StructuredAgentSessionTransition = { lifecycle: boolean /** Announce the writes once they land, when any step wrote. */ publish: boolean - /** Which steps wrote a row, once every step has landed or one failed (then none counts). */ - landed?: (wrote: readonly boolean[]) => void } const STEP_OVERFLOW = 'structured agent-session transition step exceeded its reserved size' @@ -143,13 +135,7 @@ function transitionAppend( }) .then((landed) => landed !== null) ) - let wrote: boolean[] = transition.steps.map(() => false) - try { - wrote = await Promise.all(writes) - } finally { - // Heard even when a write failed, so a writer's speculation about it always ends. - transition.landed?.(wrote) - } + const wrote = await Promise.all(writes) if (transition.publish && wrote.some(Boolean)) { bound.publish() } From 657942444a414c18957f455ea2382979708bc9c7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:15:47 -0700 Subject: [PATCH 119/269] Give every provider one close result, stderr tail and root-only default - A close reports the root and the descendants with the one verdict vocabulary (live / unverifiable / exited); `tree` is null when the close made no descendant observation, and the fallback teardown's outcome is written into it instead of a side flag a caller could miss. - The managed process drains stderr and keeps the 8 KiB tail, so no provider can forget to drain the pipe. - Root-only providers take the default close policy and completion rule; only Claude overrides them. The one already-exited guard lives in the managed close and is recorded when no earlier close ran. - A failed spawn is never read as an observed root exit. --- .../claude-agent-sdk-exit-proof.test.ts | 3 +- .../claude-agent-sdk-process-spawn.test.ts | 4 +- .../claude/claude-agent-sdk-process-spawn.ts | 16 +-- .../claude/claude-child-exit-proof-fixture.ts | 4 +- .../claude/claude-stream-json-connection.ts | 12 +- .../claude-structured-session-close.test.ts | 13 +- .../claude/claude-structured-session-close.ts | 5 +- .../codex/codex-app-server-connection.test.ts | 1 - src/main/codex/codex-app-server-connection.ts | 25 +--- ...managed-provider-process-root-only.test.ts | 123 ++++++++++++++++++ .../managed-provider-process.test.ts | 19 +-- .../managed-provider-process.ts | 47 +++++-- .../provider-process-close.ts | 43 +++--- 13 files changed, 226 insertions(+), 89 deletions(-) create mode 100644 src/main/provider-process/managed-provider-process-root-only.test.ts diff --git a/src/main/claude/claude-agent-sdk-exit-proof.test.ts b/src/main/claude/claude-agent-sdk-exit-proof.test.ts index a6abfedf3a31..675ffc113b43 100644 --- a/src/main/claude/claude-agent-sdk-exit-proof.test.ts +++ b/src/main/claude/claude-agent-sdk-exit-proof.test.ts @@ -119,11 +119,12 @@ function firstStdoutLine(child: ReturnType): Promise & { kill: ReturnType } { + Pick & { kill: ReturnType } { const child = new EventEmitter() return Object.assign(child, { pid: pid ?? undefined, stdin: new PassThrough(), + stderr: new PassThrough(), kill: vi.fn(() => true) }) as never } diff --git a/src/main/claude/claude-agent-sdk-process-spawn.test.ts b/src/main/claude/claude-agent-sdk-process-spawn.test.ts index 7838cebcd282..cadfb056a30d 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.test.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.test.ts @@ -165,8 +165,8 @@ describe('claude agent SDK process spawn', () => { process.child.stderr.write('claude: not signed in') await new Promise((resolve) => setImmediate(resolve)) - expect(spawn.stderrTail).toMatch(/claude: not signed in$/) - expect(spawn.stderrTail.length).toBe(8192) + expect(spawn.managed?.stderrTail()).toMatch(/claude: not signed in$/) + expect(spawn.managed?.stderrTail().length).toBe(8192) }) it('hands a Windows .cmd shim to Orca\u2019s argument encoder', () => { diff --git a/src/main/claude/claude-agent-sdk-process-spawn.ts b/src/main/claude/claude-agent-sdk-process-spawn.ts index 92ebb8f1d3d3..42c138290f78 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.ts @@ -9,8 +9,6 @@ import { claudeChildClosePolicy } from './claude-child-exit-proof-ladder' /** Derived rather than imported: only src/shared/child-process may name node:child_process. */ type ClaudeCodeChild = ReturnType -const STDERR_TAIL_MAX_BYTES = 8192 - export type ClaudeCodeProcessSpawn = { /** Pass as the SDK's `spawnClaudeCodeProcess`; the SDK never learns the pid because it never owns it. */ spawn: (options: ClaudeAgentSdkSpawnOptions) => ClaudeCodeChild @@ -24,7 +22,6 @@ export type ClaudeCodeProcessSpawn = { readonly pid: number | undefined /** The spawn spec's verdict, so the close ladder never re-decides it. False until the SDK spawns. */ readonly supervised: boolean - readonly stderrTail: string } function definedEnv(env: Record): Record { @@ -52,7 +49,6 @@ export function createClaudeCodeProcessSpawn( platform: NodeJS.Platform = process.platform ): ClaudeCodeProcessSpawn { let managed: ManagedProviderProcess | null = null - let stderrTail = '' return { spawn: (options) => { // SDK abort would kill the child outside Orca's ladder, losing observed exit proof. @@ -71,13 +67,8 @@ export function createClaudeCodeProcessSpawn( acceptClose: (result) => result.root === 'exited' && result.tree === 'exited' } ) - const spawned = managed.child - // The SDK drains stderr only for its own local spawn, so a custom spawner must: - // otherwise the child blocks on a full pipe and exit errors lose their tail. - spawned.stderr.setEncoding('utf8').on('data', (chunk: string) => { - stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES) - }) - return spawned + // The SDK drains stderr only for its own local spawn; the managed process drains it here. + return managed.child }, get child() { return managed?.child ?? null @@ -90,9 +81,6 @@ export function createClaudeCodeProcessSpawn( }, get supervised() { return managed?.supervised ?? false - }, - get stderrTail() { - return stderrTail } } } diff --git a/src/main/claude/claude-child-exit-proof-fixture.ts b/src/main/claude/claude-child-exit-proof-fixture.ts index 7d03431d5316..e10e156757e4 100644 --- a/src/main/claude/claude-child-exit-proof-fixture.ts +++ b/src/main/claude/claude-child-exit-proof-fixture.ts @@ -9,7 +9,7 @@ import { claudeChildClosePolicy } from './claude-child-exit-proof-ladder' const managedChildren = new WeakMap() export function managedChild( - child: Pick & EventEmitter + child: Pick & EventEmitter ): ManagedProviderProcess { const existing = managedChildren.get(child) if (existing) { @@ -18,7 +18,7 @@ export function managedChild( const managed = spawnManagedProviderProcess( { command: 'fixture-provider', args: [] }, { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Close reads only the fixture's owned pid, events, kill and stdin. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The managed process reads only the fixture's owned pid, events, kill, stdin and stderr. spawnImpl: () => child as ReturnType, platform: 'win32', site: 'claude-proof-fixture', diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index eba09ce78747..c9f5ee2689d1 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -16,7 +16,7 @@ import { } from './claude-agent-sdk-control-requests' import { createClaudeChildTreeReaper, proveClaudeChildExit } from './claude-agent-sdk-exit-proof' import { withTimeout } from '../../shared/promise-timeout-fallback' -import type { ProviderProcessVerdict } from '../provider-process/provider-process-close' +import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' import { claudeUnwrittenUserMessageError, @@ -83,8 +83,8 @@ export type ClaudeStreamJsonConnectionHandlers = { * is never collapsed into either neighbour. */ export type ClaudeChildExitVerdict = { - root: ProviderProcessVerdict - tree: ProviderProcessVerdict + root: DescendantTreeVerdict + tree: DescendantTreeVerdict processless?: boolean } @@ -202,19 +202,19 @@ export async function openClaudeStreamJsonConnection( // The SDK may synchronously spawn the CLI and consume an early stderr chunk // before this connection can attach its listener; the bounded tail preserves // that observation for the same lazy arm. - if (spawner.stderrTail.length > 0) { + if (managed.stderrTail().length > 0) { armTreeOnOutput() } const handleUnexpectedEnd = (cause?: Error): void => { resumeReading() - terminalError ??= exitError(spawner.stderrTail, exitStatus, cause) + terminalError ??= exitError(managed.stderrTail(), exitStatus, cause) inbox.fail(terminalError) if (!closing && !faultReported) { faultReported = true handlers.onFault?.(terminalError) } - if (managed.rootVerdict === 'exited' && !managed.processless && !exitReported) { + if (managed.rootExitObserved && !exitReported) { exitReported = true handlers.onExit?.(terminalError, { expected: closing }) } diff --git a/src/main/claude/claude-structured-session-close.test.ts b/src/main/claude/claude-structured-session-close.test.ts index 3d61bbb7357f..5c36899ade6d 100644 --- a/src/main/claude/claude-structured-session-close.test.ts +++ b/src/main/claude/claude-structured-session-close.test.ts @@ -13,10 +13,21 @@ import { import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' -import { closeClaudeSession } from './claude-structured-session-close' +import { claudeRootExitObserved, closeClaudeSession } from './claude-structured-session-close' import { ClaudeAcquisitionRegistry } from './claude-structured-session-state' describe('Claude published session close lifecycle', () => { + it('never reads a failed spawn as an observed root exit, whatever checks it first', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + const connection = claude.connections[0]! + connection.exitVerdict = { root: 'exited', tree: 'exited', processless: true } + expect(claudeRootExitObserved(connection)).toBe(false) + connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } + expect(claudeRootExitObserved(connection)).toBe(true) + }) + it('reports a proven root exit when published-session close cannot prove descendants', async () => { const claude = fakeClaude() const adapter = adapterFor(claude) diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index f2fa78a009b6..3093d2ead0da 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -22,11 +22,12 @@ import { settleClaudeTurnEndWaiters } from './claude-request-end-wait' import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' /** The root's own exit was seen first-hand. The lease follows the root, so a descendant - * left unverified or seen alive does not hold it. */ + * left unverified or seen alive does not hold it. A failed spawn had no process to exit. */ export function claudeRootExitObserved( connection: ClaudeStreamJsonConnection | null | undefined ): boolean { - return connection?.exitVerdict.root === 'exited' + const verdict = connection?.exitVerdict + return verdict?.root === 'exited' && verdict.processless !== true } export function claudeAcquisitionCleanupError( diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index a0c4b7ba9faf..3e41a821dc65 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -391,7 +391,6 @@ describe('openCodexAppServerConnection', () => { child.stderr.write('codex crashed\n') await flushStreams() child.emit('exit', 1, null) - child.emit('exit', 1, null) child.emit('close', 1, null) expect((await inFlight).message).toContain('codex crashed') diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 9e8466dbacda..37cad648b0cf 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -1,7 +1,6 @@ import { spawnProcess } from '../../shared/child-process/run-process' import { spawnManagedProviderProcess } from '../provider-process/managed-provider-process' import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' -import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' import { buildCodexAppServerExitError } from './codex-app-server-exit-error' import { initializeCodexAppServerConnection } from './codex-app-server-handshake' import { CodexAppServerHandshakeExitUnprovenError } from './codex-app-server-handshake-exit-proof' @@ -26,6 +25,7 @@ export { isCodexAppServerRequestError } from './codex-app-server-request-error' export { CodexAppServerFrameSizeError } from './codex-app-server-frame-size-error' +export { ROOT_ONLY_GRACEFUL_EXIT_MS as GRACEFUL_EXIT_MS } from '../provider-process/provider-process-close' // Structured chat needs a persistent bidirectional child and per-request deadlines; // the request-scoped app-server runner cannot carry approvals or streamed turns. @@ -33,9 +33,6 @@ export { CodexAppServerFrameSizeError } from './codex-app-server-frame-size-erro export type CodexAppServerLaunch = ProviderProcessLaunch const DEFAULT_REQUEST_TIMEOUT_MS = 30_000 -export const GRACEFUL_EXIT_MS = 1_500 -const FORCED_EXIT_MS = 1_000 -const STDERR_TAIL_MAX_BYTES = 8192 /** * Spawns `codex app-server`, completes the initialize handshake, and returns a @@ -49,16 +46,10 @@ export async function openCodexAppServerConnection( ): Promise { const managed = spawnManagedProviderProcess(launch, { spawnImpl, - site: 'codex-app-server-teardown', - acceptClose: (result) => result.root === 'exited', - policy: (supervised) => ({ - gracefulExitMs: supervised ? PROVIDER_SUPERVISOR_MAX_STOP_MS : GRACEFUL_EXIT_MS, - forcedExitMs: FORCED_EXIT_MS - }) + site: 'codex-app-server-teardown' }) const { child, terminateTree: terminateProcessTree } = managed - let stderrTail = '' let nextRequestId = 1 let closing = false let exitReported = false @@ -68,7 +59,7 @@ export async function openCodexAppServerConnection( let terminalError: Error | null = null function buildExitError(cause?: Error): Error { - return buildCodexAppServerExitError(stderrTail, cause) + return buildCodexAppServerExitError(managed.stderrTail(), cause) } const dispatcher = createCodexAppServerRecordDispatcher({ @@ -102,9 +93,6 @@ export async function openCodexAppServerConnection( handleUnexpectedEnd(error) }) managed.onExit(() => handleUnexpectedEnd()) - child.stderr.setEncoding('utf8').on('data', (chunk: string) => { - stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES) - }) child.stdin.on('error', (error) => { // A broken pipe is terminal, not one failed write: every later request can // only error or time out, so the session must learn its lease is worthless @@ -207,10 +195,7 @@ export async function openCodexAppServerConnection( } function close(): Promise { - if (managed.rootVerdict === 'exited') { - return Promise.resolve(true) - } - closing = true + closing ||= managed.rootVerdict !== 'exited' return managed.close().then((result) => { dispatcher.failPending(new Error('codex app-server connection closed')) return result.root === 'exited' @@ -227,7 +212,7 @@ export async function openCodexAppServerConnection( get processTreeUnproven() { return ( managed.lastCloseResult?.root === 'exited' && - managed.lastCloseResult.teardownAccepted === false + managed.lastCloseResult.tree === 'unverifiable' ) }, request, diff --git a/src/main/provider-process/managed-provider-process-root-only.test.ts b/src/main/provider-process/managed-provider-process-root-only.test.ts new file mode 100644 index 000000000000..e4cba9ddc0a4 --- /dev/null +++ b/src/main/provider-process/managed-provider-process-root-only.test.ts @@ -0,0 +1,123 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../shared/child-process/run-process' +import { spawnManagedProviderProcess } from './managed-provider-process' +import { ROOT_ONLY_GRACEFUL_EXIT_MS } from './provider-process-close' + +const teardown = vi.hoisted(() => ({ terminate: vi.fn(async () => true) })) +vi.mock('./provider-process-teardown', () => ({ + terminateProviderProcessTree: teardown.terminate +})) + +afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() +}) + +function fakeChild(pid: number | null = 9_999_999) { + const child = Object.assign(new EventEmitter(), { + pid: pid ?? undefined, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(() => true) + }) + const spawn = vi.fn(() => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The managed lifecycle reads only events, pid, streams and kill from this fixture. + return child as unknown as ReturnType + }) + return { child, spawn } +} + +/** A provider with no reaper of its own takes every close default. */ +function rootOnly(fixture: ReturnType) { + return spawnManagedProviderProcess( + { command: 'fixture-provider', args: [] }, + { spawnImpl: fixture.spawn, platform: 'win32', site: 'fixture-provider-teardown' } + ) +} + +describe('root-only managed provider close', () => { + it('reports no descendant observation when the root leaves on stdin end', async () => { + const fixture = fakeChild() + fixture.child.stdin.once('finish', () => fixture.child.emit('exit', 0, null)) + const managed = rootOnly(fixture) + await expect(managed.close()).resolves.toEqual({ root: 'exited', tree: null }) + expect(teardown.terminate).not.toHaveBeenCalled() + }) + + it.each([ + [true, 'exited'], + [false, 'unverifiable'] + ] as const)( + 'writes the fallback teardown outcome (accepted=%s) into the tree verdict', + async (accepted, tree) => { + vi.useFakeTimers() + teardown.terminate.mockImplementationOnce(async () => { + fixture.child.emit('exit', null, 'SIGKILL') + return accepted + }) + const fixture = fakeChild() + const managed = rootOnly(fixture) + const closing = managed.close() + await vi.advanceTimersByTimeAsync(ROOT_ONLY_GRACEFUL_EXIT_MS) + await expect(closing).resolves.toEqual({ root: 'exited', tree }) + // The root is gone, so the close is done: a repeat answers from the memo, not a second teardown. + await expect(managed.close()).resolves.toEqual({ root: 'exited', tree }) + expect(teardown.terminate).toHaveBeenCalledOnce() + expect(managed.lastCloseResult).toEqual({ root: 'exited', tree }) + } + ) + + it('waits the default root-only grace before forcing', async () => { + vi.useFakeTimers() + const fixture = fakeChild() + const managed = rootOnly(fixture) + void managed.close() + await vi.advanceTimersByTimeAsync(ROOT_ONLY_GRACEFUL_EXIT_MS - 1) + expect(teardown.terminate).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(teardown.terminate).toHaveBeenCalledOnce() + }) + + it('records the already-exited answer when no close ran, without touching the child', async () => { + const fixture = fakeChild() + const managed = rootOnly(fixture) + fixture.child.emit('exit', 0, null) + await expect(managed.close()).resolves.toEqual({ root: 'exited', tree: null }) + expect(managed.lastCloseResult).toEqual({ root: 'exited', tree: null }) + expect(fixture.child.stdin.writableEnded).toBe(false) + expect(fixture.child.kill).not.toHaveBeenCalled() + }) + + it('drains stderr into a bounded tail', async () => { + const fixture = fakeChild() + const managed = rootOnly(fixture) + fixture.child.stderr.write('x'.repeat(9000)) + fixture.child.stderr.write('provider: not signed in') + await new Promise((resolve) => setImmediate(resolve)) + expect(managed.stderrTail()).toMatch(/provider: not signed in$/) + expect(managed.stderrTail()).toHaveLength(8192) + }) +}) + +describe('root exit observation', () => { + it('never reads a failed spawn as an observed root exit', () => { + const fixture = fakeChild(null) + const managed = rootOnly(fixture) + fixture.child.emit('error', Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' })) + fixture.child.emit('close', -2, null) + expect(managed.rootVerdict).toBe('exited') + expect(managed.processless).toBe(true) + expect(managed.rootExitObserved).toBe(false) + }) + + it('reads a real process exit as observed', () => { + const fixture = fakeChild() + const managed = rootOnly(fixture) + expect(managed.rootExitObserved).toBe(false) + fixture.child.emit('exit', 1, null) + expect(managed.rootExitObserved).toBe(true) + }) +}) diff --git a/src/main/provider-process/managed-provider-process.test.ts b/src/main/provider-process/managed-provider-process.test.ts index a6f4c04fb772..5fdc8eea9604 100644 --- a/src/main/provider-process/managed-provider-process.test.ts +++ b/src/main/provider-process/managed-provider-process.test.ts @@ -4,7 +4,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { spawnProcess } from '../../shared/child-process/run-process' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from './provider-process-supervisor' import { spawnManagedProviderProcess } from './managed-provider-process' -import type { ProviderProcessTree, ProviderProcessVerdict } from './provider-process-close' +import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' +import type { ProviderProcessTree } from './provider-process-close' const mocks = vi.hoisted(() => ({ capture: vi.fn(async () => null), @@ -46,7 +47,7 @@ function launch(fixture: ReturnType, platform: NodeJS.Platform ) } -function fakeTree(initial: ProviderProcessVerdict = 'unverifiable') { +function fakeTree(initial: DescendantTreeVerdict = 'unverifiable') { let verdict = initial const tree: ProviderProcessTree = { capture: vi.fn(async () => {}), @@ -58,7 +59,7 @@ function fakeTree(initial: ProviderProcessVerdict = 'unverifiable') { } return { tree, - setVerdict: (next: ProviderProcessVerdict) => { + setVerdict: (next: DescendantTreeVerdict) => { verdict = next } } @@ -240,11 +241,7 @@ describe('managed provider process', () => { const close = managed.close() await vi.advanceTimersByTimeAsync(150) await expect(close).resolves.toMatchObject({ root: 'exited' }) - expect(managed.lastCloseResult).toEqual({ - root: 'exited', - tree: 'unverifiable', - teardownAccepted: false - }) + expect(managed.lastCloseResult).toEqual({ root: 'exited', tree: 'unverifiable' }) }) it('keeps the cleanup diagnostic tied to the close that finished before a late root exit', async () => { @@ -257,11 +254,7 @@ describe('managed provider process', () => { await expect(close).resolves.toMatchObject({ root: 'live' }) fixture.child.emit('exit', null, 'SIGKILL') await expect(managed.close()).resolves.toMatchObject({ root: 'exited' }) - expect(managed.lastCloseResult).toEqual({ - root: 'live', - tree: 'unverifiable', - teardownAccepted: false - }) + expect(managed.lastCloseResult).toEqual({ root: 'live', tree: 'unverifiable' }) }) it('lets a supervised caller signal immediately and waits its configured grace before forcing', async () => { diff --git a/src/main/provider-process/managed-provider-process.ts b/src/main/provider-process/managed-provider-process.ts index c80696c8cc7f..85ce254a0b2b 100644 --- a/src/main/provider-process/managed-provider-process.ts +++ b/src/main/provider-process/managed-provider-process.ts @@ -6,14 +6,18 @@ import { createProviderSpawnSpec } from './provider-process-supervisor' import { terminateProviderProcessTree } from './provider-process-teardown' +import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' import { + acceptProviderRootExit, closeProviderProcess, + rootOnlyProviderClosePolicy, type ProviderProcessClosePolicy, type ProviderProcessCloseResult, - type ProviderProcessTree, - type ProviderProcessVerdict + type ProviderProcessTree } from './provider-process-close' +const STDERR_TAIL_MAX_CHARS = 8192 + export type ProviderProcessExit = { code: number | null signal: NodeJS.Signals | null @@ -22,20 +26,29 @@ export type ProviderProcessExit = { type ManagedProviderProcessOptions = { site: string - policy: (supervised: boolean) => ProviderProcessClosePolicy + /** Defaults to the root-only policy; only a provider with its own reaper overrides it. */ + policy?: (supervised: boolean) => ProviderProcessClosePolicy spawnImpl?: typeof spawnProcess platform?: NodeJS.Platform inheritedEnv?: NodeJS.ProcessEnv - acceptClose: (result: ProviderProcessCloseResult) => boolean + /** Defaults to "the root is gone". */ + acceptClose?: (result: ProviderProcessCloseResult) => boolean } export type ManagedProviderProcess = { child: ReturnType supervised: boolean + /** The spawn failed before a process existed: absence is proven, but no exit was observed. */ readonly processless: boolean - readonly rootVerdict: ProviderProcessVerdict + /** `exited` covers a processless child too; use `rootExitObserved` for "a real process exited". */ + readonly rootVerdict: DescendantTreeVerdict + /** A process that existed was seen to exit; never true for a processless child. */ + readonly rootExitObserved: boolean + /** The last close that ran the ladder; the already-exited answer only when none did. */ readonly lastCloseResult: ProviderProcessCloseResult | null readonly exitPromise: Promise + /** The last 8 KiB of stderr, which the managed process drains so the child never blocks on it. */ + stderrTail(): string onExit(listener: (exit: ProviderProcessExit) => void): void terminateTree(): Promise close(tree?: ProviderProcessTree): Promise @@ -48,7 +61,7 @@ export function spawnManagedProviderProcess( ): ManagedProviderProcess { const platform = options.platform ?? process.platform const spec = createProviderSpawnSpec(launch, options.inheritedEnv ?? process.env, platform) - const policy = options.policy(spec.supervised) + const policy = (options.policy ?? rootOnlyProviderClosePolicy)(spec.supervised) if (spec.supervised && !(policy.gracefulExitMs >= PROVIDER_SUPERVISOR_MAX_STOP_MS)) { throw new RangeError( `Supervised provider graceful exit must wait at least ${PROVIDER_SUPERVISOR_MAX_STOP_MS} ms; received ${policy.gracefulExitMs} ms` @@ -66,7 +79,12 @@ export function spawnManagedProviderProcess( let observed: ProviderProcessExit | null = null let spawnFailed = false let lastCloseResult: ProviderProcessCloseResult | null = null - const exitProof = new RetryableProcessExitProof(options.acceptClose) + const exitProof = new RetryableProcessExitProof(options.acceptClose ?? acceptProviderRootExit) + let stderrTail = '' + // An undrained stderr pipe blocks the child once it fills. + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_CHARS) + }) let resolveExit = (): void => {} const exitPromise = new Promise((resolve) => { resolveExit = resolve @@ -92,7 +110,7 @@ export function spawnManagedProviderProcess( observeExit({ code, signal, processless }) } }) - const rootVerdict = (): ProviderProcessVerdict => + const rootVerdict = (): DescendantTreeVerdict => observed ? 'exited' : child.pid === undefined ? 'unverifiable' : 'live' const terminateTree = (): Promise => terminateProviderProcessTree(child, { site: options.site, platform }) @@ -107,6 +125,10 @@ export function spawnManagedProviderProcess( get rootVerdict() { return rootVerdict() }, + get rootExitObserved() { + return observed !== null && !observed.processless + }, + stderrTail: () => stderrTail, get lastCloseResult() { return lastCloseResult }, @@ -119,10 +141,13 @@ export function spawnManagedProviderProcess( }, terminateTree, close(tree) { - if (observed && !tree) { - return Promise.resolve({ root: 'exited', tree: 'unverifiable' }) - } return exitProof.run(async () => { + // The one already-exited guard. It observed nothing, so an earlier close's findings stay. + if (observed && !tree) { + const result: ProviderProcessCloseResult = { root: 'exited', tree: null } + lastCloseResult ??= result + return result + } const result = await closeProviderProcess({ child, exitPromise, diff --git a/src/main/provider-process/provider-process-close.ts b/src/main/provider-process/provider-process-close.ts index 9077929d17a9..fd6f7b289c9a 100644 --- a/src/main/provider-process/provider-process-close.ts +++ b/src/main/provider-process/provider-process-close.ts @@ -1,13 +1,13 @@ import type { SpawnedProcess } from '../../shared/child-process/run-process' +import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' import { waitForProcessExitUntil } from './provider-process-exit-deadline' - -export type ProviderProcessVerdict = 'live' | 'unverifiable' | 'exited' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from './provider-process-supervisor' export type ProviderProcessTree = { capture(): Promise refresh?: () => Promise - reap(): Promise - readonly treeVerdict: ProviderProcessVerdict + reap(): Promise + readonly treeVerdict: DescendantTreeVerdict } export type ProviderProcessClosePolicy = { @@ -19,7 +19,7 @@ export type ProviderProcessClosePolicy = { export type ProviderProcessCloseInput = { child: Pick exitPromise: Promise - rootVerdict: () => ProviderProcessVerdict + rootVerdict: () => DescendantTreeVerdict supervised?: boolean policy: ProviderProcessClosePolicy tree?: ProviderProcessTree @@ -27,10 +27,25 @@ export type ProviderProcessCloseInput = { } export type ProviderProcessCloseResult = { - root: ProviderProcessVerdict - tree: ProviderProcessVerdict - /** Acceptance of the fallback teardown is separate from observed process exit. */ - teardownAccepted?: boolean + root: DescendantTreeVerdict + /** Null when this close made no observation of the descendants. */ + tree: DescendantTreeVerdict | null +} + +export const ROOT_ONLY_GRACEFUL_EXIT_MS = 1_500 +const ROOT_ONLY_FORCED_EXIT_MS = 1_000 + +/** Default for providers without a descendant reaper: end stdin, wait, then the fallback teardown. */ +export function rootOnlyProviderClosePolicy(supervised: boolean): ProviderProcessClosePolicy { + return { + gracefulExitMs: supervised ? PROVIDER_SUPERVISOR_MAX_STOP_MS : ROOT_ONLY_GRACEFUL_EXIT_MS, + forcedExitMs: ROOT_ONLY_FORCED_EXIT_MS + } +} + +/** A root-only close is done once the root is gone; an unproven tree is reported, not retried. */ +export function acceptProviderRootExit(result: ProviderProcessCloseResult): boolean { + return result.root === 'exited' } /** The supervisor owns the POSIX signal ladder; its wrapper must outlive that ladder. */ @@ -50,7 +65,7 @@ export async function closeProviderProcess( child.kill('SIGTERM') } let reaped = false - let teardownAccepted: boolean | undefined + let fallbackTree: DescendantTreeVerdict | null = null if (input.rootVerdict() !== 'exited') { await waitForProcessExitUntil(input.exitPromise, policy.gracefulExitMs) if (input.rootVerdict() !== 'exited') { @@ -59,7 +74,7 @@ export async function closeProviderProcess( if (tree) { await tree.reap() } else { - teardownAccepted = await input.terminateTree() + fallbackTree = (await input.terminateTree()) ? 'exited' : 'unverifiable' } await waitForProcessExitUntil(input.exitPromise, policy.forcedExitMs) } @@ -67,9 +82,5 @@ export async function closeProviderProcess( if (!reaped && input.rootVerdict() === 'exited' && tree && tree.treeVerdict !== 'exited') { await tree.reap() } - return { - root: input.rootVerdict(), - tree: tree?.treeVerdict ?? 'unverifiable', - ...(teardownAccepted === undefined ? {} : { teardownAccepted }) - } + return { root: input.rootVerdict(), tree: tree ? tree.treeVerdict : fallbackTree } } From f3f2ccc7d7db44dbf9043befae9b67b2d121200e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:16:44 -0700 Subject: [PATCH 120/269] Generate open ACP enums and check the generated schema offline A newer or vendor enum value (tool kind, tool status, option kind, stop reason) no longer fails the whole message: generated enums accept the known literals plus any other string, typed so callers can still narrow on the known ones. The generated header now records the pinned input digests, the generator digest and a body hash, so `verify:acp-protocol` catches a stale or hand-edited file without network access; it runs in lint and the PR workflow. --- .github/workflows/pr.yml | 4 + config/scripts/acp/generate-protocol.mjs | 138 ++++++++++++------ package.json | 4 +- .../acp/acp-session-notifications.test.ts | 11 +- .../acp/generated/acp-protocol.generated.ts | 39 +++-- 5 files changed, 136 insertions(+), 60 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ed9a25732bbe..7a9b6b876fce 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -349,6 +349,10 @@ jobs: if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:rpc-params-catalog + - name: Verify the generated ACP protocol schema + if: needs.code_paths.outputs.static_analysis == 'true' + run: pnpm run verify:acp-protocol + - name: Verify bundled skill guides if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:bundled-skill-guides diff --git a/config/scripts/acp/generate-protocol.mjs b/config/scripts/acp/generate-protocol.mjs index 9e154fde5052..54d3d30b8e64 100644 --- a/config/scripts/acp/generate-protocol.mjs +++ b/config/scripts/acp/generate-protocol.mjs @@ -7,9 +7,46 @@ const release = 'schema-v1.21.0' const legacyRelease = 'v0.11.6' const repository = 'https://github.com/agentclientprotocol/agent-client-protocol' const output = fileURLToPath(new URL('../../../src/main/acp/generated/', import.meta.url)) -const check = process.argv.includes('--check') +const outputFile = 'acp-protocol.generated.ts' +// --check is offline (lint/CI): the header records every input digest and the body hash. +// --check-online regenerates from the pinned downloads and compares byte-for-byte. +const checkOnline = process.argv.includes('--check-online') +const check = checkOnline || process.argv.includes('--check') const definitions = {} -const header = `// Generated by config/scripts/acp/generate-protocol.mjs; do not edit.\n// ACP ${release}, legacy model API ${legacyRelease}; SPDX-License-Identifier: Apache-2.0.\n` +const inputs = { + schema: '7f77702b34e0a0558e77220e9007bf8ee161a976bb8ac5021aba1b7e7b2c5708', + legacy: 'b3cf8687d979c98c009f0fbcf8f0c237645b82ac2d2f0a3ebca683f963c3d581', + license: 'f250d08cee4549b22b3b4aaaf3a743473336fd280316df5d0340717e5127a221' +} +const sha256 = (text) => createHash('sha256').update(text.replace(/\r\n/g, '\n')).digest('hex') +const generatorDigest = sha256(await readFile(import.meta.filename, 'utf8')) +const header = `// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. Regenerate: pnpm run generate:acp-protocol\n// ACP ${release}, legacy model API ${legacyRelease}; SPDX-License-Identifier: Apache-2.0.\n// Inputs sha256: schema ${inputs.schema}, legacy ${inputs.legacy}, license ${inputs.license}, generator ${generatorDigest}\n` +const bodyDigestPrefix = '// Body sha256: ' + +async function checkOffline() { + const text = (await readFile(resolve(output, outputFile), 'utf8')).replace(/\r\n/g, '\n') + if (!text.startsWith(header)) { + throw new Error(`Stale generated file: ${outputFile} (inputs or generator changed; regenerate)`) + } + const rest = text.slice(header.length) + const newline = rest.indexOf('\n') + if (!rest.startsWith(bodyDigestPrefix) || newline === -1) { + throw new Error(`Stale generated file: ${outputFile} (missing body digest)`) + } + if (rest.slice(bodyDigestPrefix.length, newline) !== sha256(rest.slice(newline + 1))) { + throw new Error(`Generated file was edited by hand: ${outputFile}`) + } + for (const file of await readdir(output)) { + if ((file.endsWith('.gen.ts') || file.endsWith('.generated.ts')) && file !== outputFile) { + throw new Error(`Unexpected generated file: ${file}`) + } + } + console.log(`Checked ${outputFile} against pinned inputs offline`) +} +if (check && !checkOnline) { + await checkOffline() + process.exit(0) +} async function download(url, digest) { const response = await fetch(url) @@ -24,17 +61,11 @@ async function download(url, digest) { } const [schema, legacy, license] = await Promise.all([ - download( - `${repository}/releases/download/${release}/schema.unstable.json`, - '7f77702b34e0a0558e77220e9007bf8ee161a976bb8ac5021aba1b7e7b2c5708' - ), - download( - `${repository}/releases/download/${legacyRelease}/schema.unstable.json`, - 'b3cf8687d979c98c009f0fbcf8f0c237645b82ac2d2f0a3ebca683f963c3d581' - ), + download(`${repository}/releases/download/${release}/schema.unstable.json`, inputs.schema), + download(`${repository}/releases/download/${legacyRelease}/schema.unstable.json`, inputs.legacy), download( `https://raw.githubusercontent.com/agentclientprotocol/agent-client-protocol/${release}/LICENSE`, - 'f250d08cee4549b22b3b4aaaf3a743473336fd280316df5d0340717e5127a221' + inputs.license ) ]) Object.assign(definitions, JSON.parse(legacy).$defs, JSON.parse(schema).$defs) @@ -115,6 +146,29 @@ function visit(name) { } roots.forEach(visit) +// A named string enum: two or more string constants, optionally with an open `string` member. +function isStringEnum(value) { + const alternatives = value.oneOf ?? value.anyOf + return ( + Array.isArray(alternatives) && + alternatives.filter((alternative) => typeof alternative.const === 'string').length > 1 && + alternatives.every( + (alternative) => + typeof alternative.const === 'string' || + (alternative.type === 'string' && + Object.keys(alternative).every((key) => ['type', 'title', 'description'].includes(key))) + ) + ) +} + +// Enums stay open so a newer or vendor value reaches the caller instead of failing the message. +function openEnum(value) { + const known = (value.oneOf ?? value.anyOf).filter( + (alternative) => typeof alternative.const === 'string' + ) + return `z.union([${known.map((alternative) => `z.literal(${JSON.stringify(alternative.const)})`).join(',')},otherString])` +} + function expression(value) { if (value === true) { return 'z.unknown()' @@ -197,48 +251,44 @@ function expression(value) { return result } -const generated = new Map([ - [ - 'acp-protocol.generated.ts', - `${header}/*\n${license.trim()}\n*/\nimport { z } from 'zod'\nexport const ACP_SCHEMA_RELEASE = '${release}'\nexport const ACP_LEGACY_MODEL_SCHEMA_RELEASE = '${legacyRelease}'\nexport const ACP_PROTOCOL_VERSION = 1\n${ordered - .map( - (name) => - `export const ${name}Schema = ${expression(definitions[name])}\nexport type ${name} = z.infer\n` - ) - .join('\n')}` - ] -]) +const source = `${header}/*\n${license.trim()}\n*/\nimport { z } from 'zod'\nexport const ACP_SCHEMA_RELEASE = '${release}'\nexport const ACP_LEGACY_MODEL_SCHEMA_RELEASE = '${legacyRelease}'\nexport const ACP_PROTOCOL_VERSION = 1\n// An enum value this schema release does not name; \`string & {}\` keeps the known literals narrowable.\nconst otherString = z.custom((value) => typeof value === 'string')\n${ordered + .map( + (name) => + `export const ${name}Schema = ${isStringEnum(definitions[name]) ? openEnum(definitions[name]) : expression(definitions[name])}\nexport type ${name} = z.infer\n` + ) + .join('\n')}` // Use the repository formatter without spawning a platform-dependent executable shim. const { format } = await import('oxfmt') +const formatted = await format(outputFile, source, { + singleQuote: true, + semi: false, + printWidth: 100, + trailingComma: 'none' +}) +if (formatted.errors.length || !formatted.code.startsWith(header)) { + throw new Error(`Formatting failed for ${outputFile}`) +} +const body = formatted.code.slice(header.length) +const code = `${header}${bodyDigestPrefix}${sha256(body)}\n${body}` await mkdir(output, { recursive: true }) -for (const [file, source] of generated) { - const formatted = await format(file, source, { - singleQuote: true, - semi: false, - printWidth: 100, - trailingComma: 'none' - }) - if (formatted.errors.length) { - throw new Error(`Formatting failed for ${file}`) - } - const destination = resolve(output, file) - if (check) { - if ((await readFile(destination, 'utf8')) !== formatted.code) { - throw new Error(`Stale generated file: ${file}`) - } - } else { - await writeFile(destination, formatted.code) +const destination = resolve(output, outputFile) +if (check) { + if ((await readFile(destination, 'utf8')) !== code) { + throw new Error(`Stale generated file: ${outputFile}`) } +} else { + await writeFile(destination, code) } for (const file of await readdir(output)) { - if ((file.endsWith('.gen.ts') || file.endsWith('.generated.ts')) && !generated.has(file)) { - if (check || !(await readFile(resolve(output, file), 'utf8')).startsWith(header)) { + if ((file.endsWith('.gen.ts') || file.endsWith('.generated.ts')) && file !== outputFile) { + const generatedHere = (await readFile(resolve(output, file), 'utf8')).startsWith( + '// Generated by config/scripts/acp/generate-protocol.mjs' + ) + if (check || !generatedHere) { throw new Error(`Unexpected generated file: ${file}`) } await unlink(resolve(output, file)) } } -console.log( - `${check ? 'Checked' : 'Generated'} ${ordered.length} ACP definitions in ${generated.size} files` -) +console.log(`${check ? 'Checked' : 'Generated'} ${ordered.length} ACP definitions`) diff --git a/package.json b/package.json index 3c1979d5ff0e..e2b900443da3 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src", "test:perf:contracts": "vitest run --config config/vitest.performance.config.ts", "format": "oxfmt --write .", - "lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage", "audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor", "audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings", "audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings", @@ -50,6 +50,8 @@ "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", "generate:rpc-params-catalog": "node config/scripts/generate-rpc-params-catalog.mjs", "verify:rpc-params-catalog": "node config/scripts/generate-rpc-params-catalog.mjs --check", + "generate:acp-protocol": "node config/scripts/acp/generate-protocol.mjs", + "verify:acp-protocol": "node config/scripts/acp/generate-protocol.mjs --check", "generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write", "verify:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --check", "generate:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs --write", diff --git a/src/main/acp/acp-session-notifications.test.ts b/src/main/acp/acp-session-notifications.test.ts index b13796d2ea06..e71c3084f9ab 100644 --- a/src/main/acp/acp-session-notifications.test.ts +++ b/src/main/acp/acp-session-notifications.test.ts @@ -24,11 +24,16 @@ afterEach(() => describe('ACP session update compatibility', () => { it.each([ ['known', { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }], + // Unfamiliar enum values stay typed: the generated enums are open. + ['known', { sessionUpdate: 'tool_call', toolCallId: 't', title: 'Search', kind: 'web_search' }], + ['known', { sessionUpdate: 'tool_call_update', toolCallId: 't', status: 'cancelled' }], [ - 'unrecognized', - { sessionUpdate: 'tool_call', toolCallId: 't', title: 'Search', kind: 'web_search' } + 'known', + { + sessionUpdate: 'plan', + entries: [{ content: 'Ship', priority: 'urgent', status: 'blocked' }] + } ], - ['unrecognized', { sessionUpdate: 'tool_call_update', toolCallId: 't', status: 'cancelled' }], ['unrecognized', { sessionUpdate: 'vendor_usage', tokens: 1 }], [ 'unrecognized', diff --git a/src/main/acp/generated/acp-protocol.generated.ts b/src/main/acp/generated/acp-protocol.generated.ts index 4438895c9e31..88b6479d41f5 100644 --- a/src/main/acp/generated/acp-protocol.generated.ts +++ b/src/main/acp/generated/acp-protocol.generated.ts @@ -1,5 +1,7 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. Regenerate: pnpm run generate:acp-protocol // ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +// Inputs sha256: schema 7f77702b34e0a0558e77220e9007bf8ee161a976bb8ac5021aba1b7e7b2c5708, legacy b3cf8687d979c98c009f0fbcf8f0c237645b82ac2d2f0a3ebca683f963c3d581, license f250d08cee4549b22b3b4aaaf3a743473336fd280316df5d0340717e5127a221, generator 320945b1687098a7566f15e853c4fb846d3aa0c9bf8b6b67acbd8508fc304e0b +// Body sha256: 02c2a5308636bb6383bfa67745d530680552e562f020b705430a40b4c08c2f8e /* Apache License Version 2.0, January 2004 @@ -196,6 +198,8 @@ import { z } from 'zod' export const ACP_SCHEMA_RELEASE = 'schema-v1.21.0' export const ACP_LEGACY_MODEL_SCHEMA_RELEASE = 'v0.11.6' export const ACP_PROTOCOL_VERSION = 1 +// An enum value this schema release does not name; `string & {}` keeps the known literals narrowable. +const otherString = z.custom((value) => typeof value === 'string') export const ProtocolVersionSchema = z.number().int().min(0).max(65535) export type ProtocolVersion = z.infer @@ -283,7 +287,8 @@ export type ClientNesCapabilities = z.infer export const PositionEncodingKindSchema = z.union([ z.literal('utf-16'), z.literal('utf-32'), - z.literal('utf-8') + z.literal('utf-8'), + otherString ]) export type PositionEncodingKind = z.infer @@ -398,7 +403,11 @@ export const NesDocumentDidOpenCapabilitiesSchema = z.looseObject({ }) export type NesDocumentDidOpenCapabilities = z.infer -export const TextDocumentSyncKindSchema = z.union([z.literal('full'), z.literal('incremental')]) +export const TextDocumentSyncKindSchema = z.union([ + z.literal('full'), + z.literal('incremental'), + otherString +]) export type TextDocumentSyncKind = z.infer export const NesDocumentDidChangeCapabilitiesSchema = z.looseObject({ @@ -645,7 +654,7 @@ export const SessionConfigOptionCategorySchema = z.union([ z.literal('model'), z.literal('model_config'), z.literal('thought_level'), - z.string() + otherString ]) export type SessionConfigOptionCategory = z.infer @@ -741,7 +750,7 @@ export const ResumeSessionResponseSchema = z.looseObject({ }) export type ResumeSessionResponse = z.infer -export const RoleSchema = z.union([z.literal('assistant'), z.literal('user')]) +export const RoleSchema = z.union([z.literal('assistant'), z.literal('user'), otherString]) export type Role = z.infer export const AnnotationsSchema = z.looseObject({ @@ -838,7 +847,8 @@ export const StopReasonSchema = z.union([ z.literal('max_tokens'), z.literal('max_turn_requests'), z.literal('refusal'), - z.literal('cancelled') + z.literal('cancelled'), + otherString ]) export type StopReason = z.infer @@ -889,7 +899,8 @@ export const ToolKindSchema = z.union([ z.literal('think'), z.literal('fetch'), z.literal('switch_mode'), - z.literal('other') + z.literal('other'), + otherString ]) export type ToolKind = z.infer @@ -897,7 +908,8 @@ export const ToolCallStatusSchema = z.union([ z.literal('pending'), z.literal('in_progress'), z.literal('completed'), - z.literal('failed') + z.literal('failed'), + otherString ]) export type ToolCallStatus = z.infer @@ -969,14 +981,16 @@ export type ToolCallUpdate = z.infer export const PlanEntryPrioritySchema = z.union([ z.literal('high'), z.literal('medium'), - z.literal('low') + z.literal('low'), + otherString ]) export type PlanEntryPriority = z.infer export const PlanEntryStatusSchema = z.union([ z.literal('pending'), z.literal('in_progress'), - z.literal('completed') + z.literal('completed'), + otherString ]) export type PlanEntryStatus = z.infer @@ -1102,7 +1116,7 @@ export const CompactionStatusSchema = z.union([ z.literal('completed'), z.literal('failed'), z.literal('cancelled'), - z.string() + otherString ]) export type CompactionStatus = z.infer @@ -1185,7 +1199,8 @@ export const PermissionOptionKindSchema = z.union([ z.literal('allow_once'), z.literal('allow_always'), z.literal('reject_once'), - z.literal('reject_always') + z.literal('reject_always'), + otherString ]) export type PermissionOptionKind = z.infer From 0db8b0c8f73b8aeef5db407b1ba1f64ebe307137 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:16:44 -0700 Subject: [PATCH 121/269] Land the ACP runtime contract the agent adapters use - Deliver notifications other than session/update through onExtensionNotification, in arrival order with session updates. - Accept _meta on prompt, setMode, setModel, setConfigOption and cancel. - cancel() always sends session/cancel once the session runs, since the agent can be in a turn it began itself; only a successful send is shared, so a failed write is retried. - Cancel aborts each open agent request's signal and lets its handler send its own answer; -32800 only when the handler rejects. - Permission requests validate only the session, tool call id and options; unreadable fields are dropped with a diagnostic, and any answer Orca cannot send is `cancelled` instead of a JSON-RPC error. Agent-started turns may ask; whether to show it is the caller's decision. - AcpAgentError marks the agent's own errors; AcpInvalidResponseError keeps the raw answer and validation issues for answers Orca could not read. - Lines over the size limit are classified by prefix (shared with the Codex reader): the owed request fails, an oversized agent request is answered with an error, and an unattributable response closes the connection. --- src/main/acp/acp-errors.ts | 37 +++- src/main/acp/acp-incoming-requests.ts | 48 +++-- src/main/acp/acp-json-rpc-peer.test.ts | 44 ++++- src/main/acp/acp-json-rpc-peer.ts | 60 +++--- src/main/acp/acp-oversized-lines.ts | 38 ++++ src/main/acp/acp-peer-limits.ts | 21 +++ src/main/acp/acp-permission-requests.test.ts | 120 ++++++++++++ src/main/acp/acp-permission-requests.ts | 161 +++++++++++------ src/main/acp/acp-prompt-cancel.ts | 46 +++++ src/main/acp/acp-session-agent-turns.test.ts | 148 +++++++++++++++ src/main/acp/acp-session-events.ts | 26 +++ src/main/acp/acp-session-lifecycle.test.ts | 24 ++- src/main/acp/acp-session-runtime.test.ts | 23 ++- src/main/acp/acp-session-runtime.ts | 171 +++++++++--------- .../codex/codex-app-server-record-dispatch.ts | 2 +- .../json-rpc-record-prefix.ts} | 0 16 files changed, 759 insertions(+), 210 deletions(-) create mode 100644 src/main/acp/acp-oversized-lines.ts create mode 100644 src/main/acp/acp-peer-limits.ts create mode 100644 src/main/acp/acp-permission-requests.test.ts create mode 100644 src/main/acp/acp-prompt-cancel.ts create mode 100644 src/main/acp/acp-session-agent-turns.test.ts create mode 100644 src/main/acp/acp-session-events.ts rename src/{main/codex/codex-app-server-record-prefix.ts => shared/json-rpc-record-prefix.ts} (100%) diff --git a/src/main/acp/acp-errors.ts b/src/main/acp/acp-errors.ts index 41aff9694e2e..edcc93de077f 100644 --- a/src/main/acp/acp-errors.ts +++ b/src/main/acp/acp-errors.ts @@ -11,7 +11,16 @@ export class AcpRpcError extends Error { } } -export class AcpAuthRequiredError extends AcpRpcError { +/** The agent's own error answer to a request: it read the request and refused it. Errors Orca + * raises about a request (a timeout, an unreadable answer, a closed connection) are not this. */ +export class AcpAgentError extends AcpRpcError { + constructor(code: number, message: string, data?: unknown) { + super(code, message, data) + this.name = 'AcpAgentError' + } +} + +export class AcpAuthRequiredError extends AcpAgentError { constructor( message: string, data?: unknown, @@ -22,6 +31,32 @@ export class AcpAuthRequiredError extends AcpRpcError { } } +/** Orca could not read the agent's answer; `data` keeps the raw answer, `issues` why it failed. */ +export class AcpInvalidResponseError extends AcpRpcError { + constructor( + message: string, + raw: unknown, + readonly issues?: unknown + ) { + super(-32603, message, raw) + this.name = 'AcpInvalidResponseError' + } +} + +/** A line from the agent exceeded the framing limit, so the message it carried was never read. */ +export class AcpFrameTooLargeError extends Error { + constructor( + readonly method: string | null, + readonly observedBytes: number, + readonly maxBytes: number + ) { + super( + `ACP${method ? ` ${method}` : ''} message exceeds ${maxBytes} byte limit (${observedBytes} bytes received)` + ) + this.name = 'AcpFrameTooLargeError' + } +} + export class AcpConnectionClosedError extends Error { constructor(message = 'ACP connection closed') { super(message) diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts index b565c00c207a..92e50219e515 100644 --- a/src/main/acp/acp-incoming-requests.ts +++ b/src/main/acp/acp-incoming-requests.ts @@ -1,7 +1,12 @@ import { AcpRpcError } from './acp-errors' import type { AcpJsonRpcMessage, AcpPeerHandlers } from './acp-json-rpc-peer' -type OpenRequest = { method: string; controller: AbortController; abandon: () => void } +type OpenRequest = { + controller: AbortController + abandon: () => void + closed: boolean + cancelled: boolean +} export class AcpIncomingRequests { private readonly open = new Map() @@ -16,22 +21,20 @@ export class AcpIncomingRequests { close(error: Error): void { for (const request of this.open.values()) { + request.closed = true request.controller.abort(error) request.abandon() } this.open.clear() } - cancel(exceptMethod?: string): void { - for (const [id, request] of this.open) { - if (request.method === exceptMethod) { - continue + // The handler still answers (a permission answers `cancelled`); -32800 only if it rejects. + cancel(): void { + for (const request of this.open.values()) { + if (!request.cancelled) { + request.cancelled = true + request.controller.abort(new AcpRpcError(-32800, 'Request cancelled')) } - const error = new AcpRpcError(-32800, 'Request cancelled') - this.open.delete(id) - request.controller.abort(error) - request.abandon() - void this.sendError(id, error) } } @@ -41,7 +44,7 @@ export class AcpIncomingRequests { return } if (this.open.size >= this.capacity) { - void this.sendError(id, new AcpRpcError(-32603, 'ACP incoming request capacity exceeded')) + this.refuse(id, new AcpRpcError(-32603, 'ACP incoming request capacity exceeded')) return } const controller = new AbortController() @@ -49,9 +52,10 @@ export class AcpIncomingRequests { const abandoned = new Promise((_resolve, reject) => { abandon = () => reject(controller.signal.reason) }) - this.open.set(id, { method, controller, abandon }) + const request: OpenRequest = { controller, abandon, closed: false, cancelled: false } + this.open.set(id, request) const retire = (): void => { - if (this.open.get(id)?.controller === controller) { + if (this.open.get(id) === request) { this.open.delete(id) } } @@ -59,7 +63,7 @@ export class AcpIncomingRequests { abandoned, Promise.resolve().then(() => { if (controller.signal.aborted) { - return undefined + throw controller.signal.reason } if (!this.handler) { throw new AcpRpcError(-32601, `Unknown ACP client method: ${method}`) @@ -68,7 +72,7 @@ export class AcpIncomingRequests { }) ]) .then(async (result) => { - if (controller.signal.aborted) { + if (request.closed) { return } // The agent may reuse the id as soon as it reads the response. @@ -76,15 +80,17 @@ export class AcpIncomingRequests { await this.send({ jsonrpc: '2.0', id, result: result ?? null }) }) .catch(async (error) => { - if (controller.signal.aborted) { + if (request.closed) { return } retire() await this.sendError( id, - error instanceof AcpRpcError - ? error - : new AcpRpcError(-32603, error instanceof Error ? error.message : String(error)) + request.cancelled + ? new AcpRpcError(-32800, 'Request cancelled') + : error instanceof AcpRpcError + ? error + : new AcpRpcError(-32603, error instanceof Error ? error.message : String(error)) ) }) .finally(() => { @@ -93,6 +99,10 @@ export class AcpIncomingRequests { }) } + refuse(id: string | number | null, error: AcpRpcError): void { + void this.sendError(id, error) + } + private async sendError(id: string | number | null, error: AcpRpcError): Promise { try { await this.send({ diff --git a/src/main/acp/acp-json-rpc-peer.test.ts b/src/main/acp/acp-json-rpc-peer.test.ts index 9ead2e2ddc0c..3d74a9c1c7cc 100644 --- a/src/main/acp/acp-json-rpc-peer.test.ts +++ b/src/main/acp/acp-json-rpc-peer.test.ts @@ -1,7 +1,12 @@ import { PassThrough, Writable } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' import { AcpJsonRpcPeer, type AcpPeerHandlers, type AcpPeerOptions } from './acp-json-rpc-peer' -import { AcpConnectionClosedError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' +import { + AcpConnectionClosedError, + AcpFrameTooLargeError, + AcpRequestTimeoutError, + AcpRpcError +} from './acp-errors' import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' const peers: AcpJsonRpcPeer[] = [] @@ -98,10 +103,45 @@ describe('ACP JSON-RPC peer', () => { expect(await peer.request('valid', {})).toBe('ok') expect(notified).toEqual(['✓']) expect(diagnostics).toContain('Ignored ACP line: invalid-json') - expect(diagnostics).toContain('Ignored ACP line: line-too-long') + expect(diagnostics).toContain('Ignored ACP line: line-too-long (unknown)') expect(diagnostics).toContain('Ignored invalid ACP JSON-RPC envelope') }) + it('settles whoever was owed a message that exceeded the line limit', async () => { + const diagnostics: string[] = [] + const { peer, agent } = fixture( + { onDiagnostic: (message) => diagnostics.push(message), onRequest: () => 'unused' }, + { maxLineBytes: 200 } + ) + const filler = 'x'.repeat(300) + const big = peer.request('session/load', {}) + agent.stdout.write(`{"jsonrpc":"2.0","id":1,"result":{"history":"${filler}"}}\n`) + const lost = await big.catch((error: unknown) => error) + expect(lost).toBeInstanceOf(AcpFrameTooLargeError) + expect(lost).toMatchObject({ method: 'session/load', maxBytes: 200 }) + const answer = new Promise((resolve) => { + agent.stdin.on('data', (chunk: string) => resolve(JSON.parse(chunk))) + }) + agent.stdout.write( + `{"jsonrpc":"2.0","id":"q","method":"session/request_permission","params":{"diff":"${filler}"}}\n` + ) + expect(await answer).toMatchObject({ id: 'q', error: { code: -32600 } }) + agent.stdout.write(`{"jsonrpc":"2.0","method":"session/update","params":{"x":"${filler}"}}\n`) + agent.stdout.write(`${filler}\n`) + await tick() + expect(peer.closed).toBe(false) + expect(diagnostics).toEqual([ + 'Ignored ACP line: line-too-long (response)', + 'Ignored ACP line: line-too-long (server-request)', + 'Ignored ACP line: line-too-long (notification)', + 'Ignored ACP line: line-too-long (unknown)' + ]) + const stranded = peer.request('session/prompt', {}) + agent.stdout.write(`{"jsonrpc":"2.0","id":2,"vendor":"${filler}"}\n`) + await expect(stranded).rejects.toBeInstanceOf(AcpFrameTooLargeError) + expect(peer.closed).toBe(true) + }) + it('rejects malformed matching responses instead of leaving calls pending', async () => { const { peer, agent } = fixture() const rejected = expect(peer.request('wait', {})).rejects.toMatchObject({ code: -32603 }) diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index 08df74ef89ae..2a2cb867b08c 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -1,12 +1,18 @@ import type { Readable, Writable } from 'node:stream' import { z } from 'zod' -import { isSafeTimerDelayMs } from '../../shared/timer-delay' import { createIncrementalNdjsonFramer, encodeNdjson } from '../../shared/main-process-ndjson-framer' -import { AcpConnectionClosedError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' +import { + AcpAgentError, + AcpConnectionClosedError, + AcpInvalidResponseError, + AcpRequestTimeoutError +} from './acp-errors' import { AcpIncomingRequests } from './acp-incoming-requests' +import { bounded, requestTimeout } from './acp-peer-limits' +import { settleOversizedAcpLine } from './acp-oversized-lines' import { AcpWriteQueue } from './acp-write-queue' import { detachAcpStreamErrorHandler } from './acp-stdio-error-boundary' @@ -42,6 +48,7 @@ export type AcpPeerOptions = { requestTimeoutMs?: number | null } type Pending = { + method: string resolve: (value: unknown) => void reject: (error: Error) => void timer?: ReturnType @@ -83,7 +90,15 @@ export class AcpJsonRpcPeer { ) this.framer = createIncrementalNdjsonFramer( (record) => this.dispatch(record), - (rejected) => this.diagnose(`Ignored ACP line: ${rejected.kind}`), + (rejected) => + rejected.kind === 'line-too-long' + ? settleOversizedAcpLine(rejected, { + rejectPending: (id, error) => this.rejectPending(id, error), + refuse: (id, error) => this.incoming.refuse(id, error), + close: (error) => this.close(error), + diagnose: (message) => this.diagnose(message) + }) + : this.diagnose(`Ignored ACP line: ${rejected.kind}`), { maxLineBytes: this.maxLineBytes } ) input.setEncoding('utf8') @@ -134,7 +149,7 @@ export class AcpJsonRpcPeer { controller.abort(error) reject(error) }, timeoutMs) - this.pending.set(id, { resolve, reject, timer }) + this.pending.set(id, { method, resolve, reject, timer }) void this.send({ jsonrpc: '2.0', id, method, params }, controller.signal).catch((error) => { const pending = this.pending.get(id) if (!pending) { @@ -151,8 +166,9 @@ export class AcpJsonRpcPeer { return this.send({ jsonrpc: '2.0', method, params }) } - cancelIncomingRequests(exceptMethod?: string): void { - this.incoming.cancel(exceptMethod) + /** Aborts every open agent request's signal; each handler still sends its own answer. */ + cancelIncomingRequests(): void { + this.incoming.cancel() } close(error: Error = new AcpConnectionClosedError()): void { @@ -210,12 +226,12 @@ export class AcpJsonRpcPeer { } } - private rejectInvalidResponse(id: number, raw: unknown): void { + private rejectPending(id: number, error: (method: string) => Error): void { const pending = this.pending.get(id) if (pending) { this.pending.delete(id) clearTimeout(pending.timer) - pending.reject(new AcpRpcError(-32603, 'Invalid ACP response envelope', raw)) + pending.reject(error(pending.method)) } } @@ -230,7 +246,7 @@ export class AcpJsonRpcPeer { .object({ id: z.number(), method: z.undefined().optional() }) .safeParse(record) if (response.success) { - this.rejectInvalidResponse(response.data.id, record) + this.rejectPending(response.data.id, invalidEnvelope(record)) } return } @@ -254,7 +270,7 @@ export class AcpJsonRpcPeer { if ('result' in frame === 'error' in frame) { this.diagnose('Ignored invalid ACP response') if (typeof frame.id === 'number') { - this.rejectInvalidResponse(frame.id, record) + this.rejectPending(frame.id, invalidEnvelope(record)) } return } @@ -271,10 +287,10 @@ export class AcpJsonRpcPeer { const parsedError = errorSchema.safeParse(frame.error) if (!parsedError.success) { this.diagnose('Invalid ACP error response') - pending.reject(new AcpRpcError(-32603, 'Invalid ACP error response', frame.error)) + pending.reject(new AcpInvalidResponseError('Invalid ACP error response', frame.error)) } else { const error = parsedError.data - pending.reject(new AcpRpcError(error.code, error.message, error.data)) + pending.reject(new AcpAgentError(error.code, error.message, error.data)) } } else { pending.resolve(frame.result) @@ -282,22 +298,6 @@ export class AcpJsonRpcPeer { } } -function bounded(value: number | undefined, fallback: number): number { - if (value === undefined) { - return fallback - } - if (!Number.isSafeInteger(value) || value <= 0) { - throw new Error('ACP limits must be positive finite integers') - } - return value -} - -function requestTimeout(value: number | null | undefined): number | null { - if (value == null) { - return null - } - if (!isSafeTimerDelayMs(value) || value <= 0) { - throw new Error('ACP timeouts must be positive finite timer durations') - } - return value +function invalidEnvelope(raw: unknown): () => Error { + return () => new AcpInvalidResponseError('Invalid ACP response envelope', raw) } diff --git a/src/main/acp/acp-oversized-lines.ts b/src/main/acp/acp-oversized-lines.ts new file mode 100644 index 000000000000..a4329b847821 --- /dev/null +++ b/src/main/acp/acp-oversized-lines.ts @@ -0,0 +1,38 @@ +import { classifyJsonRpcPrefix } from '../../shared/json-rpc-record-prefix' +import type { NdjsonRejectedRecord } from '../../shared/main-process-ndjson-framer' +import { AcpFrameTooLargeError, AcpRpcError } from './acp-errors' + +export type AcpOversizedLineTarget = { + rejectPending: (id: number, error: (method: string) => Error) => void + refuse: (id: string | number, error: AcpRpcError) => void + close: (error: Error) => void + diagnose: (message: string) => void +} + +/** The Codex reader's prefix classification: settle whoever was owed the message that was lost. */ +export function settleOversizedAcpLine( + rejected: NdjsonRejectedRecord & { kind: 'line-too-long' }, + target: AcpOversizedLineTarget +): void { + const { observedBytes, maxLineBytes } = rejected + const record = classifyJsonRpcPrefix(rejected.prefix) + target.diagnose(`Ignored ACP line: line-too-long (${record.kind})`) + if (record.kind === 'server-request') { + target.refuse( + record.id, + new AcpRpcError(-32600, `ACP request exceeds ${maxLineBytes} byte limit`, { + method: record.method, + observedBytes + }) + ) + } else if (record.kind === 'response') { + target.rejectPending( + record.id, + (method) => new AcpFrameTooLargeError(method, observedBytes, maxLineBytes) + ) + } else if (record.kind === 'response-unknown') { + // A numeric id with no readable result: any pending call could be the one that never settles. + target.close(new AcpFrameTooLargeError(null, observedBytes, maxLineBytes)) + } + // A notification or non-JSON-RPC output (a stray log line) settles nothing; closing would end the session. +} diff --git a/src/main/acp/acp-peer-limits.ts b/src/main/acp/acp-peer-limits.ts new file mode 100644 index 000000000000..51f98809472e --- /dev/null +++ b/src/main/acp/acp-peer-limits.ts @@ -0,0 +1,21 @@ +import { isSafeTimerDelayMs } from '../../shared/timer-delay' + +export function bounded(value: number | undefined, fallback: number): number { + if (value === undefined) { + return fallback + } + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error('ACP limits must be positive finite integers') + } + return value +} + +export function requestTimeout(value: number | null | undefined): number | null { + if (value == null) { + return null + } + if (!isSafeTimerDelayMs(value) || value <= 0) { + throw new Error('ACP timeouts must be positive finite timer durations') + } + return value +} diff --git a/src/main/acp/acp-permission-requests.test.ts b/src/main/acp/acp-permission-requests.test.ts new file mode 100644 index 000000000000..f51a7429853c --- /dev/null +++ b/src/main/acp/acp-permission-requests.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' +import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' +import type { RequestPermissionRequest } from './generated/acp-protocol.generated' + +const opened: { close: () => void }[] = [] +const startOptions = { cwd: '/runtime/project', mcpServers: [] } +const allow = { optionId: 'allow', name: 'Allow', kind: 'allow_once' } +const toolCall = { toolCallId: 'tool-1', title: 'Edit file' } +function fixture(options: AcpSessionRuntimeOptions = {}) { + const agent = new AcpScriptedAgent() + const diagnostics: string[] = [] + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, { + onDiagnostic: (message) => diagnostics.push(message), + ...options + }) + opened.push(agent, runtime) + agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1 })) + agent.on('session/new', (frame) => agent.reply(frame, { sessionId: 'session-1' })) + agent.on('session/prompt', () => {}) + return { agent, runtime, diagnostics } +} +afterEach(() => + opened + .splice(0) + .toReversed() + .forEach((resource) => resource.close()) +) + +describe('ACP permission requests', () => { + it.each([ + ['a newer tool kind', { ...toolCall, kind: 'web_search' }, allow], + ['a newer tool status', { ...toolCall, status: 'cancelled' }, allow], + ['a newer option kind', toolCall, { ...allow, kind: 'allow_for_session' }] + ])('delivers a permission with %s to the caller unchanged', async (_label, call, option) => { + const asked = vi.fn((_request: RequestPermissionRequest) => ({ + outcome: { outcome: 'selected' as const, optionId: option.optionId } + })) + const { agent, runtime, diagnostics } = fixture({ onPermission: asked }) + await runtime.start(startOptions) + void runtime.prompt([{ type: 'text', text: 'hi' }]).catch(() => {}) + const params = { sessionId: 'session-1', toolCall: call, options: [option] } + expect(await agent.request('p', 'session/request_permission', params)).toMatchObject({ + result: { outcome: { outcome: 'selected', optionId: option.optionId } } + }) + expect(asked.mock.calls[0][0]).toEqual(params) + expect(diagnostics).toEqual([]) + }) + + it('delivers a permission whose other fields are unreadable, dropping only those fields', async () => { + const asked = vi.fn((_request: RequestPermissionRequest) => ({ + outcome: { outcome: 'cancelled' as const } + })) + const { agent, runtime, diagnostics } = fixture({ onPermission: asked }) + await runtime.start(startOptions) + void runtime.prompt([{ type: 'text', text: 'hi' }]).catch(() => {}) + const content = [{ type: 'content', content: { type: 'reasoning_summary', text: 'x' } }] + await agent.request('p', 'session/request_permission', { + sessionId: 'session-1', + toolCall: { ...toolCall, content, vendor: 'kept' }, + options: [{ optionId: 'allow', kind: 'allow_once' }, { name: 'No id' }] + }) + expect(asked.mock.calls[0][0]).toEqual({ + sessionId: 'session-1', + toolCall: { ...toolCall, vendor: 'kept' }, + options: [{ optionId: 'allow', name: 'allow', kind: 'allow_once' }] + }) + expect(diagnostics).toEqual([ + 'Delivered ACP permission request without unreadable fields: options.1, toolCall.content' + ]) + }) + + it.each([ + [ + 'the handler throws', + () => { + throw new Error('renderer gone') + }, + 'handler failed: Error: renderer gone' + ], + ['the handler answers nonsense', () => ({ outcome: 'yes' }), 'invalid handler response'] + ])('answers cancelled with a diagnostic when %s', async (_label, onPermission, problem) => { + // Reflect.apply returns any, so a deliberately ill-typed answer needs no type assertion. + const handler: AcpSessionRuntimeOptions['onPermission'] = (request, context) => + Reflect.apply(onPermission, undefined, [request, context]) + const { agent, runtime, diagnostics } = fixture({ onPermission: handler }) + await runtime.start(startOptions) + void runtime.prompt([{ type: 'text', text: 'hi' }]).catch(() => {}) + expect( + await agent.request('p', 'session/request_permission', { + sessionId: 'session-1', + toolCall, + options: [allow] + }) + ).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect(diagnostics).toEqual([`Answered ACP permission request cancelled: ${problem}`]) + }) + + it('lets a turn the agent began itself ask for permission and cancels it with session/cancel', async () => { + const asked = deferred() + const { agent, runtime } = fixture({ + onPermission: (_request, context) => { + asked.resolve(context.signal) + return new Promise(() => {}) + } + }) + await runtime.start(startOptions) + const answer = agent.request('p', 'session/request_permission', { + sessionId: 'session-1', + toolCall, + options: [allow] + }) + const signal = await asked.promise + await runtime.cancel() + expect(signal.aborted).toBe(true) + expect(await answer).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + await tick() + expect(agent.frames.filter((frame) => frame.method === 'session/cancel')).toHaveLength(1) + }) +}) diff --git a/src/main/acp/acp-permission-requests.ts b/src/main/acp/acp-permission-requests.ts index 4701359d11b1..c39f4a4c9022 100644 --- a/src/main/acp/acp-permission-requests.ts +++ b/src/main/acp/acp-permission-requests.ts @@ -1,7 +1,10 @@ -import { AcpRpcError } from './acp-errors' +import { z } from 'zod' import type { AcpRequestContext } from './acp-json-rpc-peer' import { + PermissionOptionSchema, + RequestPermissionRequestSchema, RequestPermissionResponseSchema, + ToolCallUpdateSchema, type RequestPermissionRequest, type RequestPermissionResponse } from './generated/acp-protocol.generated' @@ -12,64 +15,112 @@ export type AcpPermissionHandler = ( ) => RequestPermissionResponse | Promise const cancelled: RequestPermissionResponse = { outcome: { outcome: 'cancelled' } } -type OpenPermission = { sessionId: string; cancel: () => void } +const routingSchema = z.looseObject({ + sessionId: z.string(), + toolCall: z.looseObject({ toolCallId: z.string() }), + options: z.array(z.unknown()) +}) +const optionRoutingSchema = z.looseObject({ optionId: z.string() }) -export class AcpPermissionRequests { - private readonly open = new Set() - - cancel(sessionId: string): void { - for (const permission of this.open) { - if (permission.sessionId === sessionId) { - permission.cancel() - } +// Keeps every field the schema does not know or can read; drops (and names) the unreadable ones. +function readableFields( + shape: Record, + value: Record, + path: string, + dropped: string[] +): Record { + const kept: Record = {} + for (const [key, field] of Object.entries(value)) { + if (!Object.hasOwn(shape, key) || shape[key].safeParse(field).success) { + kept[key] = field + } else { + dropped.push(`${path}${key}`) } } + return kept +} - handle( - request: RequestPermissionRequest, - context: AcpRequestContext, - handler: AcpPermissionHandler | undefined - ): Promise { - if (!handler || context.signal.aborted) { - return Promise.resolve(cancelled) +/** Validates only what answering needs (session, tool call id, options with ids); null if unusable. */ +export function readAcpPermissionRequest( + params: unknown, + diagnose: (message: string) => void +): RequestPermissionRequest | null { + const strict = RequestPermissionRequestSchema.safeParse(params) + if (strict.success) { + return strict.data + } + const routing = routingSchema.safeParse(params) + if (!routing.success) { + return null + } + const { toolCall, options: offered, ...rest } = routing.data + const dropped: string[] = [] + const options = offered.flatMap((option, index) => { + const ids = optionRoutingSchema.safeParse(option) + if (!ids.success) { + dropped.push(`options.${index}`) + return [] } - return new Promise((resolve, reject) => { - const controller = new AbortController() - let settled = false - const finish = (response: RequestPermissionResponse | Error): void => { - if (settled) { - return - } - settled = true - this.open.delete(permission) - context.signal.removeEventListener('abort', permission.cancel) - controller.abort() - if (response instanceof Error) { - reject(response) - } else { - resolve(response) - } + const named = { + ...ids.data, + name: typeof ids.data.name === 'string' ? ids.data.name : ids.data.optionId + } + const parsed = PermissionOptionSchema.safeParse(named) + if (!parsed.success) { + dropped.push(`options.${index}`) + return [] + } + return [parsed.data] + }) + if (options.length === 0) { + return null + } + const request = RequestPermissionRequestSchema.safeParse({ + ...readableFields(RequestPermissionRequestSchema.shape, rest, '', dropped), + toolCall: readableFields(ToolCallUpdateSchema.shape, toolCall, 'toolCall.', dropped), + options + }) + if (!request.success) { + return null + } + diagnose(`Delivered ACP permission request without unreadable fields: ${dropped.join(', ')}`) + return request.data +} + +/** Asks the caller; any answer Orca cannot send (a throw, a bad reply, an unoffered option) is `cancelled`. */ +export function answerAcpPermission( + request: RequestPermissionRequest, + context: AcpRequestContext, + handler: AcpPermissionHandler | undefined, + diagnose: (message: string) => void +): Promise { + if (!handler || context.signal.aborted) { + return Promise.resolve(cancelled) + } + return new Promise((resolve) => { + const controller = new AbortController() + let settled = false + const finish = (response: RequestPermissionResponse, problem?: string): void => { + if (settled) { + return } - const permission: OpenPermission = { - sessionId: request.sessionId, - cancel: () => finish(cancelled) + settled = true + context.signal.removeEventListener('abort', onAbort) + controller.abort() + if (problem) { + diagnose(`Answered ACP permission request cancelled: ${problem}`) } - this.open.add(permission) - context.signal.addEventListener('abort', permission.cancel, { once: true }) - void Promise.resolve() - .then(() => { - if (controller.signal.aborted) { - return cancelled - } - return handler(request, { id: context.id, signal: controller.signal }) - }) - .then((response) => { - if (settled) { - return - } + resolve(response) + } + const onAbort = (): void => finish(cancelled) + context.signal.addEventListener('abort', onAbort, { once: true }) + void Promise.resolve() + .then(() => handler(request, { id: context.id, signal: controller.signal })) + .then( + (response) => { const parsed = RequestPermissionResponseSchema.safeParse(response) if (!parsed.success) { - finish(new AcpRpcError(-32603, 'Invalid permission handler response')) + finish(cancelled, 'invalid handler response') return } const outcome = parsed.data.outcome @@ -77,12 +128,12 @@ export class AcpPermissionRequests { outcome.outcome === 'selected' && !request.options.some((option) => option.optionId === outcome.optionId) ) { - finish(new AcpRpcError(-32603, 'Permission handler selected an unavailable option')) + finish(cancelled, 'handler selected an unavailable option') return } finish(parsed.data) - }) - .catch((error) => finish(error instanceof Error ? error : new Error(String(error)))) - }) - } + }, + (error) => finish(cancelled, `handler failed: ${String(error)}`) + ) + }) } diff --git a/src/main/acp/acp-prompt-cancel.ts b/src/main/acp/acp-prompt-cancel.ts new file mode 100644 index 000000000000..5208c6df1465 --- /dev/null +++ b/src/main/acp/acp-prompt-cancel.ts @@ -0,0 +1,46 @@ +import { AcpRequestTimeoutError } from './acp-errors' +import type { PromptResponse } from './generated/acp-protocol.generated' + +export type ActivePrompt = { + response: Promise + cancelling: boolean + cancelPromise?: Promise +} + +/** Sends `session/cancel` and waits, bounded, for Orca's prompt to settle; past the bound it closes. */ +export async function confirmAcpPromptCancel( + active: ActivePrompt, + connection: { + send: () => Promise + close: (error: Error) => void + closed: () => boolean + timeoutMs: number + } +): Promise { + let timer: ReturnType | undefined + const unconfirmed = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + const error = new AcpRequestTimeoutError('session/cancel') + connection.close(error) + reject(error) + }, connection.timeoutMs) + }) + try { + await Promise.race([connection.send(), unconfirmed]).catch((error) => { + // Only a cancel that reached the agent is shared; a failed write is retried next call. + active.cancelPromise = undefined + active.cancelling = false + throw error + }) + await Promise.race([ + active.response.catch((error) => { + if (connection.closed()) { + throw error + } + }), + unconfirmed + ]) + } finally { + clearTimeout(timer) + } +} diff --git a/src/main/acp/acp-session-agent-turns.test.ts b/src/main/acp/acp-session-agent-turns.test.ts new file mode 100644 index 000000000000..474ba298184b --- /dev/null +++ b/src/main/acp/acp-session-agent-turns.test.ts @@ -0,0 +1,148 @@ +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it } from 'vitest' +import { AcpAgentError, AcpInvalidResponseError } from './acp-errors' +import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' +import { AcpScriptedAgent, type FakeFrame, tick } from './acp-scripted-agent.test-support' + +const opened: { close: () => void }[] = [] +const startOptions = { cwd: '/runtime/project', mcpServers: [] } +const prompt = [{ type: 'text', text: 'hello' }] as const +function fixture(options: AcpSessionRuntimeOptions = {}) { + const agent = new AcpScriptedAgent() + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, options) + opened.push(agent, runtime) + agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1 })) + agent.on('session/new', (frame) => agent.reply(frame, { sessionId: 'session-1' })) + return { agent, runtime } +} +afterEach(() => + opened + .splice(0) + .toReversed() + .forEach((resource) => resource.close()) +) + +describe('ACP turns the agent begins and the runtime contract around them', () => { + it('delivers extension notifications in arrival order with session updates', async () => { + const order: string[] = [] + const { agent, runtime } = fixture({ + onExtensionNotification: (method, params) => order.push(`${method} ${JSON.stringify(params)}`) + }) + runtime.subscribe((event) => order.push(`update ${event.kind}`)) + await runtime.start(startOptions) + const completed = { sessionId: 'session-1', update: { type: 'turn_completed', prompt_id: 'p' } } + agent.stdout.write( + [ + { jsonrpc: '2.0', method: '_x.ai/session_notification', params: { started: true } }, + { + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId: 'session-1', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'x' } } + } + }, + { jsonrpc: '2.0', method: '_x.ai/session_notification', params: completed } + ] + .map((frame) => `${JSON.stringify(frame)}\n`) + .join('') + ) + await tick() + expect(order).toEqual([ + '_x.ai/session_notification {"started":true}', + 'update known', + `_x.ai/session_notification ${JSON.stringify(completed)}` + ]) + }) + + it('sends session/cancel with no prompt of Orca running, and _meta on every session call', async () => { + const { agent, runtime } = fixture() + agent.on('session/prompt', (frame) => agent.reply(frame, { stopReason: 'end_turn' })) + agent.on('session/set_mode', (frame) => agent.reply(frame, {})) + agent.on('session/set_model', (frame) => agent.reply(frame, {})) + agent.on('session/set_config_option', (frame) => agent.reply(frame, { configOptions: [] })) + await runtime.start(startOptions) + const meta = { traceId: 't' } + await runtime.prompt([...prompt], meta) + await runtime.setMode('plan', meta) + await runtime.setModel('model-1', meta) + await runtime.setConfigOption('effort', 'high', meta) + await runtime.cancel({ meta }) + await tick() + const sent = agent.frames.filter((frame) => frame.method?.startsWith('session/')) + expect(sent.map((frame) => frame.method)).toEqual([ + 'session/new', + 'session/prompt', + 'session/set_mode', + 'session/set_model', + 'session/set_config_option', + 'session/cancel' + ]) + for (const frame of sent.slice(1)) { + expect(frame.params).toMatchObject({ sessionId: 'session-1', _meta: meta }) + } + }) + + it("separates the agent's own errors from answers Orca could not read", async () => { + const { agent, runtime } = fixture() + await runtime.start(startOptions) + agent.on('session/set_mode', (frame) => agent.fail(frame, -32603, 'Agent broke', { why: 1 })) + const refused = await runtime.setMode('plan').catch((error: unknown) => error) + expect(refused).toBeInstanceOf(AcpAgentError) + expect(refused).toMatchObject({ code: -32603, data: { why: 1 } }) + agent.on('session/set_mode', (frame) => agent.reply(frame, 'not-an-object')) + const unreadable = await runtime.setMode('plan').catch((error: unknown) => error) + expect(unreadable).toBeInstanceOf(AcpInvalidResponseError) + expect(unreadable).not.toBeInstanceOf(AcpAgentError) + expect(unreadable).toMatchObject({ code: -32603, data: 'not-an-object' }) + expect(unreadable instanceof AcpInvalidResponseError && unreadable.issues).toBeTruthy() + }) + + it('completes a turn whose stop reason is newer than this schema', async () => { + const { agent, runtime } = fixture() + agent.on('session/prompt', (frame) => agent.reply(frame, { stopReason: 'context_exhausted' })) + await runtime.start(startOptions) + expect(await runtime.prompt([...prompt])).toEqual({ stopReason: 'context_exhausted' }) + }) + + it('retries a cancel whose write failed instead of returning the stale failure', async () => { + const agent = new AcpScriptedAgent() + const stdin = new PassThrough({ highWaterMark: 1 }) + const runtime = new AcpSessionRuntime(agent.stdout, stdin, { + peer: { maxQueuedWriteBytes: 1000 } + }) + opened.push(agent, runtime) + const frames: FakeFrame[] = [] + let promptFrame: FakeFrame | undefined + let buffer = '' + stdin.setEncoding('utf8').on('data', (chunk: string) => { + buffer += chunk + let newline: number + while ((newline = buffer.indexOf('\n')) !== -1) { + const frame: FakeFrame = JSON.parse(buffer.slice(0, newline)) + buffer = buffer.slice(newline + 1) + frames.push(frame) + if (frame.method === 'initialize') { + agent.reply(frame, { protocolVersion: 1 }) + } else if (frame.method === 'session/new') { + agent.reply(frame, { sessionId: 'session-1' }) + } else if (frame.method === 'session/prompt') { + promptFrame = frame + stdin.pause() + } else if (frame.method === 'session/cancel' && promptFrame) { + agent.reply(promptFrame, { stopReason: 'cancelled' }) + } + } + }) + await runtime.start(startOptions) + const turn = runtime.prompt([...prompt]) + await tick() + void runtime.setMode('y'.repeat(850)).catch(() => {}) + await expect(runtime.cancel()).rejects.toThrow(/capacity/) + stdin.resume() + await tick() + await runtime.cancel() + expect(await turn).toEqual({ stopReason: 'cancelled' }) + expect(frames.filter((frame) => frame.method === 'session/cancel')).toHaveLength(1) + }) +}) diff --git a/src/main/acp/acp-session-events.ts b/src/main/acp/acp-session-events.ts new file mode 100644 index 000000000000..47dd54c57a88 --- /dev/null +++ b/src/main/acp/acp-session-events.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' +import { + SessionNotificationSchema, + type SessionNotification +} from './generated/acp-protocol.generated' + +const updateEnvelopeSchema = z.looseObject({ + sessionId: z.string(), + update: z.looseObject({ sessionUpdate: z.string() }) +}) + +export type AcpSessionEvent = + | { kind: 'known'; notification: SessionNotification } + | { kind: 'unrecognized'; sessionId: string; raw: z.infer } + +/** A `session/update`, typed when this build knows its kind; null when it is not one at all. */ +export function readAcpSessionEvent(params: unknown): AcpSessionEvent | null { + const envelope = updateEnvelopeSchema.safeParse(params) + if (!envelope.success) { + return null + } + const parsed = SessionNotificationSchema.safeParse(params) + return parsed.success + ? { kind: 'known', notification: parsed.data } + : { kind: 'unrecognized', sessionId: envelope.data.sessionId, raw: envelope.data } +} diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts index 43f2815697da..092ed11e186c 100644 --- a/src/main/acp/acp-session-lifecycle.test.ts +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -247,24 +247,28 @@ describe('ACP caller-owned waits', () => { await expect(authenticated).resolves.toEqual({}) }) - it('lets cancel abort vendor decision hooks with request-cancelled instead of a timeout', async () => { - const entered = deferred() + it('lets cancel abort vendor hooks so each sends its own answer, or request-cancelled if it rejects', async () => { const { agent, runtime } = fixture({ - onRequest: (_method, _params, context) => { - entered.resolve(context.signal) - return new Promise(() => {}) - } + onRequest: (method, _params, context) => + new Promise((resolve, reject) => + context.signal.addEventListener('abort', () => + method === '_vendor/plan' + ? resolve({ outcome: 'abandoned' }) + : reject(new Error('stop')) + ) + ) }) agent.on('session/prompt', (frame) => agent.on('session/cancel', () => agent.reply(frame, { stopReason: 'cancelled' })) ) await runtime.start(startOptions) const pending = runtime.prompt([...prompt]) - const response = agent.request('question', '_vendor/question', {}) - const signal = await entered.promise + const question = agent.request('question', '_vendor/question', {}) + const plan = agent.request('plan', '_vendor/plan', {}) + await tick() await runtime.cancel() - expect(signal.aborted).toBe(true) - expect(await response).toMatchObject({ error: { code: -32800 } }) + expect(await question).toMatchObject({ error: { code: -32800 } }) + expect(await plan).toMatchObject({ result: { outcome: 'abandoned' } }) await pending }) diff --git a/src/main/acp/acp-session-runtime.test.ts b/src/main/acp/acp-session-runtime.test.ts index 068da3f4742f..b89e0b58059f 100644 --- a/src/main/acp/acp-session-runtime.test.ts +++ b/src/main/acp/acp-session-runtime.test.ts @@ -324,26 +324,35 @@ describe('ACP session runtime', () => { await expect(malformed.runtime.start(startOptions)).rejects.toMatchObject({ code: -32603 }) }) - it('rejects invalid permission requests and fails closed on an unavailable selected option', async () => { + it('rejects unroutable permission requests and answers an unavailable selection cancelled', async () => { + const diagnostics: string[] = [] const { runtime, agent } = fixture( {}, { - onPermission: () => ({ outcome: { outcome: 'selected', optionId: 'not-offered' } }) + onPermission: () => ({ outcome: { outcome: 'selected', optionId: 'not-offered' } }), + onDiagnostic: (message) => diagnostics.push(message) } ) agent.on('session/prompt', () => {}) await runtime.start(startOptions) - const prompt = runtime.prompt([...textPrompt]) - const rejected = expect(prompt).rejects.toBeInstanceOf(AcpConnectionClosedError) + void runtime.prompt([...textPrompt]).catch(() => {}) expect(await agent.request('invalid', 'session/request_permission', {})).toMatchObject({ error: { code: -32602 } }) + expect( + await agent.request('no-options', 'session/request_permission', { + ...permission, + options: [{ name: 'No id', kind: 'allow_once' }] + }) + ).toMatchObject({ error: { code: -32602 } }) expect( await agent.request('bad-selection', 'session/request_permission', permission) - ).toMatchObject({ error: { code: -32603 } }) + ).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect(diagnostics).toContain( + 'Answered ACP permission request cancelled: handler selected an unavailable option' + ) + // The session stays usable: a local permission failure is not a protocol failure. await expect(runtime.prompt([...textPrompt])).rejects.toThrow('already in progress') - runtime.close() - await rejected }) it('preserves unrecognized updates and isolates event listener failures', async () => { diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 77e317362d13..7d185c108c4d 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -1,44 +1,51 @@ import type { Readable, Writable } from 'node:stream' -import { z } from 'zod' +import type { z } from 'zod' import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' -import { AcpAuthRequiredError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' +import { + AcpAgentError, + AcpAuthRequiredError, + AcpInvalidResponseError, + AcpRpcError +} from './acp-errors' import { AcpJsonRpcPeer, type AcpPeerOptions, type AcpRequestContext } from './acp-json-rpc-peer' -import { AcpPermissionRequests, type AcpPermissionHandler } from './acp-permission-requests' +import { + answerAcpPermission, + readAcpPermissionRequest, + type AcpPermissionHandler +} from './acp-permission-requests' +import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' +import { confirmAcpPromptCancel, type ActivePrompt } from './acp-prompt-cancel' import { setupAcpSession, type AcpSessionStarted, type AcpSessionStartOptions } from './acp-session-setup' export type { AcpSessionStarted, AcpSessionStartOptions } from './acp-session-setup' +export type { AcpSessionEvent } from './acp-session-events' import { ACP_PROTOCOL_VERSION, InitializeResponseSchema, AuthenticateResponseSchema, PromptResponseSchema, - RequestPermissionRequestSchema, - SessionNotificationSchema, SetSessionModeResponseSchema, SetSessionModelResponseSchema, SetSessionConfigOptionResponseSchema, type InitializeRequest, type InitializeResponse, type AuthenticateResponse, + type CancelNotification, type PromptRequest, type PromptResponse, - type SessionNotification, type SetSessionConfigOptionRequest, type SetSessionConfigOptionResponse, + type SetSessionModeRequest, type SetSessionModeResponse, + type SetSessionModelRequest, type SetSessionModelResponse } from './generated/acp-protocol.generated' -const updateEnvelopeSchema = z.looseObject({ - sessionId: z.string(), - update: z.looseObject({ sessionUpdate: z.string() }) -}) -export type AcpSessionEvent = - | { kind: 'known'; notification: SessionNotification } - | { kind: 'unrecognized'; sessionId: string; raw: z.infer } +type Meta = PromptRequest['_meta'] +const withMeta = (meta: Meta): { _meta?: Meta } => (meta ? { _meta: meta } : {}) export type AcpSessionRuntimeOptions = { clientInfo?: InitializeRequest['clientInfo'] @@ -46,18 +53,15 @@ export type AcpSessionRuntimeOptions = { cancelTimeoutMs?: number onPermission?: AcpPermissionHandler onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown + /** Agent notifications other than `session/update` (protocol extensions), delivered + * synchronously in arrival order with the `subscribe` events. */ + onExtensionNotification?: (method: string, params: unknown) => void onDiagnostic?: (message: string) => void onClose?: (error: Error) => void } -type ActivePrompt = { - response: Promise - cancelling: boolean - cancelPromise?: Promise -} export class AcpSessionRuntime { private readonly peer: AcpJsonRpcPeer - private readonly permissions = new AcpPermissionRequests() private readonly listeners = new Set<(event: AcpSessionEvent) => void>() private initialized?: Promise private starting?: Promise @@ -148,14 +152,14 @@ export class AcpSessionRuntime { return this.starting } - async prompt(prompt: PromptRequest['prompt']): Promise { + async prompt(prompt: PromptRequest['prompt'], meta?: Meta): Promise { if (this.activePrompt) { throw new Error('ACP prompt already in progress') } - const sessionId = this.sessionId() + const params: PromptRequest = { sessionId: this.sessionId(), prompt, ...withMeta(meta) } const active: ActivePrompt = { cancelling: false, - response: this.call('session/prompt', { sessionId, prompt }, PromptResponseSchema) + response: this.call('session/prompt', params, PromptResponseSchema) } this.activePrompt = active active.response = active.response.finally(() => { @@ -166,70 +170,64 @@ export class AcpSessionRuntime { return active.response } - cancel(): Promise { + /** Sends `session/cancel` whenever the session runs: the agent may be in a turn it began itself. + * With Orca's prompt running, also waits (bounded) for that prompt to settle. */ + cancel(options: { meta?: CancelNotification['_meta'] } = {}): Promise { if (!this.started) { return Promise.reject(new Error('ACP session has not started')) } - const sessionId = this.started.sessionId - this.permissions.cancel(sessionId) - this.peer.cancelIncomingRequests('session/request_permission') + const params: CancelNotification = { + sessionId: this.started.sessionId, + ...withMeta(options.meta) + } + this.peer.cancelIncomingRequests() const active = this.activePrompt if (!active) { - return Promise.resolve() + return this.peer.notify('session/cancel', params) } active.cancelling = true - active.cancelPromise ??= this.cancelActive(sessionId, active) + active.cancelPromise ??= confirmAcpPromptCancel(active, { + send: () => this.peer.notify('session/cancel', params), + close: (error) => this.peer.close(error), + closed: () => this.peer.closed, + timeoutMs: Math.min(this.options.cancelTimeoutMs ?? 10_000, MAX_TIMER_DELAY_MS) + }) return active.cancelPromise } - private async cancelActive(sessionId: string, active: ActivePrompt): Promise { - const timeoutMs = Math.min(this.options.cancelTimeoutMs ?? 10_000, MAX_TIMER_DELAY_MS) - let timer: ReturnType | undefined - try { - await Promise.race([ - this.peer.notify('session/cancel', { sessionId }).then(() => - active.response.catch((error) => { - if (this.peer.closed) { - throw error - } - }) - ), - new Promise((_resolve, reject) => { - timer = setTimeout(() => { - const error = new AcpRequestTimeoutError('session/cancel') - this.peer.close(error) - reject(error) - }, timeoutMs) - }) - ]) - } finally { - clearTimeout(timer) - } - } - - async setMode(modeId: string): Promise { - return this.call( - 'session/set_mode', - { sessionId: this.sessionId(), modeId }, - SetSessionModeResponseSchema - ) + async setMode(modeId: string, meta?: Meta): Promise { + const params: SetSessionModeRequest = { sessionId: this.sessionId(), modeId, ...withMeta(meta) } + return this.call('session/set_mode', params, SetSessionModeResponseSchema) } - async setModel(modelId: string): Promise { - return this.call( - 'session/set_model', - { sessionId: this.sessionId(), modelId }, - SetSessionModelResponseSchema - ) + async setModel(modelId: string, meta?: Meta): Promise { + const params: SetSessionModelRequest = { + sessionId: this.sessionId(), + modelId, + ...withMeta(meta) + } + return this.call('session/set_model', params, SetSessionModelResponseSchema) } async setConfigOption( configId: SetSessionConfigOptionRequest['configId'], - value: SetSessionConfigOptionRequest['value'] + value: SetSessionConfigOptionRequest['value'], + meta?: Meta ): Promise { const sessionId = this.sessionId() const request = typeof value === 'boolean' - ? ({ configId, value, sessionId, type: 'boolean' } satisfies SetSessionConfigOptionRequest) - : ({ configId, value, sessionId } satisfies SetSessionConfigOptionRequest) + ? ({ + configId, + value, + sessionId, + type: 'boolean', + ...withMeta(meta) + } satisfies SetSessionConfigOptionRequest) + : ({ + configId, + value, + sessionId, + ...withMeta(meta) + } satisfies SetSessionConfigOptionRequest) return this.call('session/set_config_option', request, SetSessionConfigOptionResponseSchema) } @@ -248,14 +246,18 @@ export class AcpSessionRuntime { private async call(method: string, params: unknown, schema: z.ZodType): Promise { const result = await this.peer.request(method, params, { timeoutMs: null }).catch((error) => { - if (error instanceof AcpRpcError && error.code === -32000) { + if (error instanceof AcpAgentError && error.code === -32000) { throw new AcpAuthRequiredError(error.message, error.data) } throw error }) const parsed = schema.safeParse(result) if (!parsed.success) { - throw new AcpRpcError(-32603, `Invalid ACP response: ${method}`) + throw new AcpInvalidResponseError( + `Invalid ACP response: ${method}`, + result, + parsed.error.issues + ) } return parsed.data } @@ -267,18 +269,16 @@ export class AcpSessionRuntime { } return this.options.onRequest(method, params, context) } - const parsed = RequestPermissionRequestSchema.safeParse(params) - if (!parsed.success) { + const diagnose = (message: string): void => this.diagnose(message) + const request = readAcpPermissionRequest(params, diagnose) + if (!request) { throw new AcpRpcError(-32602, 'Invalid ACP permission request') } - if ( - !this.activePrompt || - this.activePrompt.cancelling || - parsed.data.sessionId !== this.started?.sessionId - ) { + // Whether a turn the agent began itself may ask is the caller's call; the runtime cannot see it. + if (this.activePrompt?.cancelling || request.sessionId !== this.started?.sessionId) { return { outcome: { outcome: 'cancelled' } } } - return this.permissions.handle(parsed.data, context, this.options.onPermission) + return answerAcpPermission(request, context, this.options.onPermission, diagnose) } private diagnose(message: string): void { @@ -291,18 +291,19 @@ export class AcpSessionRuntime { private handleNotification(method: string, params: unknown): void { if (method !== 'session/update') { + try { + this.options.onExtensionNotification?.(method, params) + } catch (error) { + this.diagnose(`ACP extension listener failed: ${String(error)}`) + } return } - const envelope = updateEnvelopeSchema.safeParse(params) - if (!envelope.success) { + const event = readAcpSessionEvent(params) + if (!event) { this.diagnose('Ignored invalid ACP session update envelope') return } - const parsed = SessionNotificationSchema.safeParse(params) - const event: AcpSessionEvent = parsed.success - ? { kind: 'known', notification: parsed.data } - : { kind: 'unrecognized', sessionId: envelope.data.sessionId, raw: envelope.data } - if (!parsed.success && !this.reportedUpdateAnomaly) { + if (event.kind === 'unrecognized' && !this.reportedUpdateAnomaly) { this.reportedUpdateAnomaly = true this.diagnose('Forwarded unrecognized ACP session update') } diff --git a/src/main/codex/codex-app-server-record-dispatch.ts b/src/main/codex/codex-app-server-record-dispatch.ts index cbac80f3643e..078d672293ea 100644 --- a/src/main/codex/codex-app-server-record-dispatch.ts +++ b/src/main/codex/codex-app-server-record-dispatch.ts @@ -7,7 +7,7 @@ import { CodexAppServerUnsupportedError, isCodexMethodNotFoundError } from './codex-app-server-session' -import { classifyJsonRpcPrefix } from './codex-app-server-record-prefix' +import { classifyJsonRpcPrefix } from '../../shared/json-rpc-record-prefix' const OVERSIZED_REQUEST_ERROR_CODE = -32001 const MAX_REMEMBERED_TIMEOUTS = 64 diff --git a/src/main/codex/codex-app-server-record-prefix.ts b/src/shared/json-rpc-record-prefix.ts similarity index 100% rename from src/main/codex/codex-app-server-record-prefix.ts rename to src/shared/json-rpc-record-prefix.ts From 4178e5a0385c0c16e8a62534e0067357defb0139 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:30:08 -0700 Subject: [PATCH 122/269] refactor(agent-session): name the handle's adapter state resumeCursor Rename the neutral provider handle's providerData to resumeCursor before any row persists the neutral form: it is an adapter-owned resume position (Claude's transcript leaf), never identity. Claude/Codex stored and wire bytes are unchanged; their typed shapes never carried the field. State the stored-form contract (a handle's field set is closed; later per-link data goes on the chain link, which every build preserves) and pin it with a record round-trip test. Document that transport records the id space the native id was minted in, which can differ from the agent's current transport. --- .../claude-structured-resume-point.test.ts | 2 +- ...laude-structured-turn-resume-point.test.ts | 6 +-- ...session-provider-handle-transition.test.ts | 6 +-- .../agent-session-record-store.test.ts | 2 +- ...ude-structured-session-integration.test.ts | 4 +- .../agent-session-provider-handle-encoding.ts | 37 +++++++++--------- .../agent-session-provider-handle.test.ts | 8 ++-- src/shared/agent-session-provider-handle.ts | 17 ++++++--- .../agent-session-record-stored-form.test.ts | 38 +++++++++++++++---- 9 files changed, 77 insertions(+), 43 deletions(-) diff --git a/src/main/claude/claude-structured-resume-point.test.ts b/src/main/claude/claude-structured-resume-point.test.ts index 0b68903c618d..9465feba5d3d 100644 --- a/src/main/claude/claude-structured-resume-point.test.ts +++ b/src/main/claude/claude-structured-resume-point.test.ts @@ -88,7 +88,7 @@ describe('Claude resume point is the last completed turn on every exit path', () spawnToken: 'spawn-7', events: recordingJournalSink() }) - expect(acquisition.link.handle).toMatchObject({ providerData: 'a3' }) + expect(acquisition.link.handle).toMatchObject({ resumeCursor: 'a3' }) await expect(adapter.closeSession('session-1')).resolves.toBe(true) expect(persisted).toEqual([expect.objectContaining({ leafUuid: 'a3' })]) }) diff --git a/src/main/claude/claude-structured-turn-resume-point.test.ts b/src/main/claude/claude-structured-turn-resume-point.test.ts index fd6790b2deb8..3ffd74aff14f 100644 --- a/src/main/claude/claude-structured-turn-resume-point.test.ts +++ b/src/main/claude/claude-structured-turn-resume-point.test.ts @@ -115,12 +115,12 @@ describe('Claude durable resume point at turn end', () => { const { adapter, store, turn } = await liveOwner() try { await turn('u1', 'a1') - expect(store.record.providerHandleChain.at(-1)?.handle).toMatchObject({ providerData: 'a1' }) + expect(store.record.providerHandleChain.at(-1)?.handle).toMatchObject({ resumeCursor: 'a1' }) await turn('u2', 'a2') expect(store.record.providerHandleChain).toHaveLength(2) expect(store.record.providerHandleChain.at(-1)).toMatchObject({ linkId: 'published-link', - handle: { providerData: 'a2' } + handle: { resumeCursor: 'a2' } }) } finally { await adapter.closeAll() @@ -135,7 +135,7 @@ describe('Claude durable resume point at turn end', () => { frame({ type: 'user', uuid: 'u3' }) await tick() const head = store.record.providerHandleChain.at(-1)!.handle - expect(head).toMatchObject({ providerData: 'a2' }) + expect(head).toMatchObject({ resumeCursor: 'a2' }) const resolve = createClaudeStructuredLaunchResolver({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only getRecord from its store. diff --git a/src/main/runtime/agent-session-provider-handle-transition.test.ts b/src/main/runtime/agent-session-provider-handle-transition.test.ts index fa3eea6ecf6d..e58827d2fd54 100644 --- a/src/main/runtime/agent-session-provider-handle-transition.test.ts +++ b/src/main/runtime/agent-session-provider-handle-transition.test.ts @@ -34,7 +34,7 @@ describe('recordAgentSessionProviderHandle', () => { link: resumedLink(record.lease.runtimeFence), now: 4_000 }) - expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ providerData: 'leaf-2' }) + expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ resumeCursor: 'leaf-2' }) expect(next.lease.provenHandleLinkId).toBe('link-2') }) @@ -51,7 +51,7 @@ describe('recordAgentSessionProviderHandle', () => { link: resumedLink(lease.runtimeFence), now: 4_000 }) - expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ providerData: 'leaf-2' }) + expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ resumeCursor: 'leaf-2' }) expect(next.lease).toMatchObject({ claimStatus: 'reserved', provenHandleLinkId: null }) }) }) @@ -72,7 +72,7 @@ describe('reviseAgentSessionProviderResumePoint', () => { expect(second.providerHandleChain.at(-1)).toMatchObject({ linkId: 'link-1', origin: 'created', - handle: { providerData: 'leaf-3' } + handle: { resumeCursor: 'leaf-3' } }) expect(second.lease.provenHandleLinkId).toBe('link-1') expect(isPersistedAgentSessionRecord(encodeAgentSessionRecord(second))).toBe(true) diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index b03317f144d0..cc0a83cc55f2 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -753,7 +753,7 @@ describe('claim keys and unreadable rows', () => { transport: 'acp', agent: 'grok', nativeId: 'acp-thread', - providerData: 'resume-token' + resumeCursor: 'resume-token' }) }) const reopened = await open() diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index ce95893c69cd..bbafd5f1aae4 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -775,7 +775,7 @@ describe('a structured Claude session over agentSession.*', () => { deps: { store: { getRecord: (sessionId: string) => { - providerHandleChain: { handle: { transport: string; providerData?: string } }[] + providerHandleChain: { handle: { transport: string; resumeCursor?: string } }[] } } } @@ -783,7 +783,7 @@ describe('a structured Claude session over agentSession.*', () => { // A completed turn advances the durable resume point in place while the owner is live. expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)?.handle).toMatchObject({ transport: 'claude-sdk', - providerData: 'assistant-leaf' + resumeCursor: 'assistant-leaf' }) // A Claude Stop ends its child once Claude ends the stopped turn, so the chat rests; the next // open resumes the conversation. diff --git a/src/shared/agent-session-provider-handle-encoding.ts b/src/shared/agent-session-provider-handle-encoding.ts index da799fa26798..c0c2f6c6f57f 100644 --- a/src/shared/agent-session-provider-handle-encoding.ts +++ b/src/shared/agent-session-provider-handle-encoding.ts @@ -7,6 +7,9 @@ * in the neutral shape, which an older build refuses as unreadable rather than mistaking for one * of its own. Decoding is strict: each handle has exactly one stored form, and the key and root * strings derived from it are persisted elsewhere, so none of them may ever change. + * + * A stored handle has a closed field set, and a rewrite drops anything else. A later build adds new + * per-link data on the chain link, which every build preserves, never on the handle. */ import type { @@ -56,7 +59,7 @@ export function agentSessionProviderHandleBelongsTo( ) } -/** Claude's provider data is its resume leaf: the transcript entry a resume continues from. */ +/** Claude's resume cursor is its leaf: the transcript entry a resume continues from. */ export function claudeProviderHandle( sessionId: string, leafUuid: string | null @@ -64,12 +67,12 @@ export function claudeProviderHandle( return { ...CLAUDE_STRUCTURED_HANDLE_NAMESPACE, nativeId: sessionId, - ...(leafUuid === null ? {} : { providerData: leafUuid }) + ...(leafUuid === null ? {} : { resumeCursor: leafUuid }) } } export function claudeProviderHandleLeafUuid(handle: AgentSessionProviderHandle): string | null { - return handle.providerData ?? null + return handle.resumeCursor ?? null } export function codexProviderHandle(threadId: string): AgentSessionProviderHandle { @@ -81,7 +84,7 @@ export function codexProviderHandle(threadId: string): AgentSessionProviderHandl const MAX_HANDLE_FIELD_LENGTH = 512 /** A generic handle's key carries escaped ids, so it may outgrow a legacy key's bound. */ const MAX_NEUTRAL_HANDLE_KEY_LENGTH = 8192 -const MAX_PROVIDER_DATA_LENGTH = 4096 +const MAX_RESUME_CURSOR_LENGTH = 4096 const NAMESPACE_PART_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/ export function isAgentSessionProviderHandleField(value: unknown): value is string { @@ -106,7 +109,7 @@ function isLegacyNamespace(handle: AgentSessionProviderHandleNamespace): boolean /** * An in-memory handle. A Claude or Codex handle must also fit its typed stored shape: Claude's - * provider data is a leaf id, and Codex has none. + * resume cursor is a leaf id, and Codex has none. */ export function isAgentSessionProviderHandle(value: unknown): value is AgentSessionProviderHandle { if (typeof value !== 'object' || value === null || Object.hasOwn(value, 'provider')) { @@ -115,7 +118,7 @@ export function isAgentSessionProviderHandle(value: unknown): value is AgentSess const transport = 'transport' in value ? value.transport : undefined const agent = 'agent' in value ? value.agent : undefined const nativeId = 'nativeId' in value ? value.nativeId : undefined - const providerData = 'providerData' in value ? value.providerData : undefined + const resumeCursor = 'resumeCursor' in value ? value.resumeCursor : undefined if ( !isNamespacePart(transport) || !isNamespacePart(agent) || @@ -125,16 +128,16 @@ export function isAgentSessionProviderHandle(value: unknown): value is AgentSess } const namespace = { transport, agent } if (isAgentSessionProviderHandleInNamespace(namespace, CODEX_STRUCTURED_HANDLE_NAMESPACE)) { - return providerData === undefined + return resumeCursor === undefined } if (isAgentSessionProviderHandleInNamespace(namespace, CLAUDE_STRUCTURED_HANDLE_NAMESPACE)) { - return providerData === undefined || isAgentSessionProviderHandleField(providerData) + return resumeCursor === undefined || isAgentSessionProviderHandleField(resumeCursor) } return ( - providerData === undefined || - (typeof providerData === 'string' && - providerData.length > 0 && - providerData.length <= MAX_PROVIDER_DATA_LENGTH) + resumeCursor === undefined || + (typeof resumeCursor === 'string' && + resumeCursor.length > 0 && + resumeCursor.length <= MAX_RESUME_CURSOR_LENGTH) ) } @@ -163,7 +166,7 @@ export function encodePersistedAgentSessionProviderHandle( handle: AgentSessionProviderHandle ): PersistedAgentSessionProviderHandle { if (isAgentSessionProviderHandleInNamespace(handle, CLAUDE_STRUCTURED_HANDLE_NAMESPACE)) { - return { provider: 'claude', sessionId: handle.nativeId, leafUuid: handle.providerData ?? null } + return { provider: 'claude', sessionId: handle.nativeId, leafUuid: handle.resumeCursor ?? null } } if (isAgentSessionProviderHandleInNamespace(handle, CODEX_STRUCTURED_HANDLE_NAMESPACE)) { return { provider: 'codex', threadId: handle.nativeId } @@ -172,12 +175,12 @@ export function encodePersistedAgentSessionProviderHandle( transport: handle.transport, agent: handle.agent, nativeId: handle.nativeId, - ...(handle.providerData === undefined ? {} : { providerData: handle.providerData }) + ...(handle.resumeCursor === undefined ? {} : { resumeCursor: handle.resumeCursor }) } } /** The neutral form's discriminator and identity fields; a typed stored handle carries none of them. */ -const NEUTRAL_HANDLE_FIELDS = ['transport', 'agent', 'nativeId', 'providerData'] as const +const NEUTRAL_HANDLE_FIELDS = ['transport', 'agent', 'nativeId', 'resumeCursor'] as const export function decodePersistedAgentSessionProviderHandle( value: unknown @@ -215,7 +218,7 @@ export function decodePersistedAgentSessionProviderHandle( transport: value.transport, agent: value.agent, nativeId: value.nativeId, - ...(value.providerData === undefined ? {} : { providerData: value.providerData }) + ...(value.resumeCursor === undefined ? {} : { resumeCursor: value.resumeCursor }) } } @@ -229,7 +232,7 @@ export function agentSessionProviderHandleKey(handle: AgentSessionProviderHandle ? `claude:${JSON.stringify([stored.sessionId, stored.leafUuid])}` : `codex:${JSON.stringify(stored.threadId)}` } - // Why: provider data is resume state the adapter owns, not identity; only Claude's leaf, a + // Why: the resume cursor is resume state the adapter owns, not identity; only Claude's leaf, a // branch cursor, was ever part of a key, and its key above is fixed by records already written. return agentSessionProviderHandleRoot(handle) } diff --git a/src/shared/agent-session-provider-handle.test.ts b/src/shared/agent-session-provider-handle.test.ts index 463eff97ade7..d66c03568151 100644 --- a/src/shared/agent-session-provider-handle.test.ts +++ b/src/shared/agent-session-provider-handle.test.ts @@ -55,7 +55,7 @@ describe('handle identity', () => { it('distinguishes a null leaf from an empty-string leaf and rejects malformed handles', () => { expect(isAgentSessionProviderHandle(claudeProviderHandle('sess-1', null))).toBe(true) - expect(isAgentSessionProviderHandle({ ...CLAUDE, providerData: '' })).toBe(false) + expect(isAgentSessionProviderHandle({ ...CLAUDE, resumeCursor: '' })).toBe(false) expect(isAgentSessionProviderHandle({ ...CLAUDE, nativeId: '' })).toBe(false) expect(isAgentSessionProviderHandle({ ...CLAUDE, nativeId: ' sess-1 ' })).toBe(false) // A stored typed shape is not an in-memory handle: it must be decoded first. @@ -460,11 +460,11 @@ describe('superseding a creation the provider never saved', () => { }) describe('a transport shared code has never heard of', () => { - const acp = (nativeId: string, providerData?: string): AgentSessionProviderHandle => ({ + const acp = (nativeId: string, resumeCursor?: string): AgentSessionProviderHandle => ({ transport: 'acp', agent: 'grok', nativeId, - ...(providerData === undefined ? {} : { providerData }) + ...(resumeCursor === undefined ? {} : { resumeCursor }) }) it('chains resumes and forks by its native id alone', () => { @@ -495,7 +495,7 @@ describe('a transport shared code has never heard of', () => { expect(forked.at(-1)?.handle.nativeId).toBe('s-2') }) - it('records a same-fence resume that only moved provider data instead of eliding it', () => { + it('records a same-fence resume that only moved the resume cursor instead of eliding it', () => { const created = link({ handle: acp('s-1', 'a') }) const moved = link({ linkId: 'link-2', origin: 'resumed', handle: acp('s-1', 'b') }) expect(appendAgentSessionProviderHandleLink([created], moved)).toHaveLength(2) diff --git a/src/shared/agent-session-provider-handle.ts b/src/shared/agent-session-provider-handle.ts index c16c87e1490a..3bf8fb69b06c 100644 --- a/src/shared/agent-session-provider-handle.ts +++ b/src/shared/agent-session-provider-handle.ts @@ -3,7 +3,7 @@ * * A handle names one provider conversation without saying what the provider means by it. Shared * code reads only its transport, its agent, and the provider's own conversation id; anything else - * the provider needs to resume is `providerData`, which only that provider's adapter reads. How a + * the provider needs to resume is `resumeCursor`, which only that provider's adapter reads. How a * handle is stored and sent lives in agent-session-provider-handle-encoding.ts. * * Resumes extend the chain, forks start a new identity root, and the chain records which is which @@ -44,13 +44,20 @@ export function isAgentSessionHandleProvider(value: unknown): value is AgentSess export type AgentSessionProviderTransport = 'claude-sdk' | 'codex-app-server' | (string & {}) export type AgentSessionProviderHandle = { + /** + * The id space `nativeId` was minted in, which can differ from the agent's current transport. A + * mismatch with the running build makes the chat not resumable there, never the record unreadable. + */ transport: AgentSessionProviderTransport /** Orca agent whose binary resumes the conversation; one transport serves many agents. */ agent: AgentType /** The provider's own conversation id: Claude's session id, Codex's thread id. */ nativeId: string - /** Resume state only the provider's adapter reads, such as Claude's branch leaf. */ - providerData?: string + /** + * Adapter-owned resume position (Claude: transcript leaf). Shared code never parses it; it is never + * identity, except inside Claude's legacy handle key. + */ + resumeCursor?: string } /** Which provider a handle belongs to. Records and leases compare this, never the handle's data. */ @@ -82,7 +89,7 @@ export const MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS = 256 const LINK_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ -/** Every field, provider data included: a resume that only moved the adapter's state is still news. */ +/** Every field, resume cursor included: a resume that only moved the adapter's state is still news. */ export function agentSessionProviderHandlesEqual( left: AgentSessionProviderHandle, right: AgentSessionProviderHandle @@ -91,7 +98,7 @@ export function agentSessionProviderHandlesEqual( left.transport === right.transport && left.agent === right.agent && left.nativeId === right.nativeId && - left.providerData === right.providerData + left.resumeCursor === right.resumeCursor ) } diff --git a/src/shared/agent-session-record-stored-form.test.ts b/src/shared/agent-session-record-stored-form.test.ts index 3c4381a2f432..22ef2b54a9ad 100644 --- a/src/shared/agent-session-record-stored-form.test.ts +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -113,7 +113,7 @@ describe('a record row older builds wrote', () => { } const { record } = decodePersistedAgentSessionRecord(stored) expect(record.providerHandleChain.map((link) => link.handle)).toEqual([ - { transport: 'claude-sdk', agent: 'claude', nativeId: 'sess-1', providerData: 'leaf-1' }, + { transport: 'claude-sdk', agent: 'claude', nativeId: 'sess-1', resumeCursor: 'leaf-1' }, { transport: 'claude-sdk', agent: 'claude', nativeId: 'sess-2' } ]) }) @@ -170,7 +170,7 @@ describe('what this build writes', () => { transport: 'acp', agent: 'grok', nativeId: 'acp-session-1', - providerData: '{"cwd":"/repo"}' + resumeCursor: '{"cwd":"/repo"}' } const stored = encodePersistedAgentSessionProviderHandle(grok) expect(stored).toEqual(grok) @@ -181,7 +181,7 @@ describe('what this build writes', () => { expect(olderBuildReadsHandle(stored)).toBe(false) expect(isAgentSessionHandleProvider(grok.agent)).toBe(false) expect(agentSessionWireProviderHandle(grok)).toBeNull() - // Root, not provider data: provider data is resume state, not identity. + // Root, not the resume cursor: the cursor is resume state, not identity. expect(agentSessionProviderHandleRoot(grok)).toBe('acp/grok:"acp-session-1"') expect(agentSessionProviderHandleKey(grok)).toBe(agentSessionProviderHandleRoot(grok)) }) @@ -199,7 +199,7 @@ describe('what this build writes', () => { transport: 'acp', agent: 'grok', nativeId: 'acp-thread', - providerData: 'resume-token' + resumeCursor: 'resume-token' }, { provider: 'codex', threadId: 'thread-1', nativeId: 'other-thread' }, { @@ -210,12 +210,12 @@ describe('what this build writes', () => { agent: 'grok', nativeId: 'other' }, - { provider: 'claude', sessionId: 'sess-1', leafUuid: null, providerData: 'leaf-2' }, + { provider: 'claude', sessionId: 'sess-1', leafUuid: null, resumeCursor: 'leaf-2' }, { provider: 'claude', sessionId: 'sess-1' }, { provider: 'claude', sessionId: 'sess-1', leafUuid: '' }, { provider: 'codex', threadId: ' padded ' }, { transport: 'a:b', agent: 'grok', nativeId: 'x' }, - { transport: 'acp', agent: 'grok', nativeId: 'x', providerData: '' }, + { transport: 'acp', agent: 'grok', nativeId: 'x', resumeCursor: '' }, null ]) { expect(decodePersistedAgentSessionProviderHandle(value)).toBeNull() @@ -236,6 +236,30 @@ describe('what this build writes', () => { ).toEqual(claudeProviderHandle('sess-1', 'leaf-1')) }) + it('drops a field a later build put on a handle, but keeps one on the link or record', () => { + const stored = JSON.parse(STORED_CODEX_ROW) + stored.providerHandleChain[0].handle.laterHandleField = 'x' + stored.providerHandleChain[0].laterLinkField = 'y' + stored.laterRecordField = 'z' + expect(isPersistedAgentSessionRecord(stored)).toBe(true) + if (!isPersistedAgentSessionRecord(stored)) { + return + } + const written = JSON.parse( + JSON.stringify(encodeAgentSessionRecord(decodePersistedAgentSessionRecord(stored).record)) + ) + expect(written.laterRecordField).toBe('z') + expect(written.providerHandleChain[0].laterLinkField).toBe('y') + expect(written.providerHandleChain[0].handle).toEqual({ + provider: 'codex', + threadId: 'thread-new' + }) + + const neutral = { transport: 'acp', agent: 'grok', nativeId: 's', resumeCursor: 'c' } + const decoded = decodePersistedAgentSessionProviderHandle({ ...neutral, laterHandleField: 1 }) + expect(decoded && encodePersistedAgentSessionProviderHandle(decoded)).toEqual(neutral) + }) + it.each([ ['Claude', STORED_CLAUDE_ROW], ['Codex', STORED_CODEX_ROW] @@ -245,7 +269,7 @@ describe('what this build writes', () => { transport: 'acp', agent: 'grok', nativeId: 'acp-thread', - providerData: 'resume-token' + resumeCursor: 'resume-token' }) expect(isPersistedAgentSessionRecord(stored)).toBe(false) }) From 0b9aa438823edaa0076a3f140e5b3e6ebb60175b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:30:54 -0700 Subject: [PATCH 123/269] Report only observed descendant exits from the fallback teardown The fallback teardown answered "accepted", and the close turned that into `tree: 'exited'`, though a Windows tree kill's outcome is never read and an unreadable process table observes nothing. It now returns what it observed: `exited` only when the captured descendants were verified gone (or the kernel reported the group empty), `live` / `unverifiable` as verification found them, and null when it signalled without observing. Codex's process-tree diagnostic fires in exactly the cases it did before. --- ...pp-server-connection-tree-unproven.test.ts | 64 ++++++++++++++++ src/main/codex/codex-app-server-connection.ts | 4 +- .../codex-structured-session-cancel.test.ts | 4 +- ...ged-provider-process-fallback-tree.test.ts | 76 +++++++++++++++++++ ...managed-provider-process-root-only.test.ts | 16 ++-- .../managed-provider-process.ts | 9 ++- .../provider-process-close.ts | 5 +- .../provider-process-teardown.test.ts | 74 +++++++++++++++--- .../provider-process-teardown.ts | 52 ++++++++----- 9 files changed, 259 insertions(+), 45 deletions(-) create mode 100644 src/main/codex/codex-app-server-connection-tree-unproven.test.ts create mode 100644 src/main/provider-process/managed-provider-process-fallback-tree.test.ts diff --git a/src/main/codex/codex-app-server-connection-tree-unproven.test.ts b/src/main/codex/codex-app-server-connection-tree-unproven.test.ts new file mode 100644 index 000000000000..82e7b03dab13 --- /dev/null +++ b/src/main/codex/codex-app-server-connection-tree-unproven.test.ts @@ -0,0 +1,64 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../shared/child-process/run-process' +import type { ProviderProcessTeardownVerdict } from '../provider-process/provider-process-teardown' +import { openCodexAppServerConnection } from './codex-app-server-connection' + +const teardown = vi.hoisted(() => ({ + verdict: null as ProviderProcessTeardownVerdict, + rootExits: (): void => {} +})) +vi.mock('../provider-process/provider-process-teardown', () => ({ + terminateProviderProcessTree: vi.fn(async () => { + teardown.rootExits() + return teardown.verdict + }) +})) + +afterEach(() => { + vi.useRealTimers() +}) + +function stubChild() { + const child = Object.assign(new EventEmitter(), { + pid: 9_999_999, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(() => true) + }) + child.stdin.once('data', () => { + child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`) + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The connection reads only events, pid, streams and kill from this stub. + const spawnImpl = (() => child) as unknown as typeof spawnProcess + return { child, spawnImpl } +} + +describe('Codex process-tree diagnostic after a forced close', () => { + // The flag keeps its meaning: a forced teardown that did not prove the descendants gone. + it.each([ + ['unverifiable', true], + ['live', true], + ['exited', false], + [null, false] + ] as const)('teardown observing %s reads unproven=%s', async (verdict, unproven) => { + vi.useFakeTimers() + teardown.verdict = verdict + const { child, spawnImpl } = stubChild() + teardown.rootExits = () => child.emit('exit', null, 'SIGKILL') + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + {}, + spawnImpl + ) + const closing = connection.close() + await vi.advanceTimersByTimeAsync(10_000) + await expect(closing).resolves.toBe(true) + expect(connection.processTreeUnproven).toBe(unproven) + // A repeat close answers from the memo and keeps the diagnostic. + await expect(connection.close()).resolves.toBe(true) + expect(connection.processTreeUnproven).toBe(unproven) + }) +}) diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 37cad648b0cf..0739ca65af69 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -210,9 +210,9 @@ export async function openCodexAppServerConnection( return closing || managed.rootVerdict === 'exited' || terminalError !== null }, get processTreeUnproven() { + const tree = managed.lastCloseResult?.tree return ( - managed.lastCloseResult?.root === 'exited' && - managed.lastCloseResult.tree === 'unverifiable' + managed.lastCloseResult?.root === 'exited' && (tree === 'unverifiable' || tree === 'live') ) }, request, diff --git a/src/main/codex/codex-structured-session-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts index cd30bfa2e6d1..76961eb96bd8 100644 --- a/src/main/codex/codex-structured-session-cancel.test.ts +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -10,6 +10,7 @@ const processWork = vi.hoisted(() => { capturedAtMs: 0 })), terminateDescendantSnapshotAndWait: vi.fn(never), + terminateDescendantSnapshotWithVerdict: vi.fn(never), queryWindowsProcessDescendants: vi.fn(never), terminateWindowsProcessTree: vi.fn(never) } @@ -20,7 +21,8 @@ vi.mock('../pty-descendant-termination', async (importOriginal) => ({ })) vi.mock('../pty-descendant-exit-verification', async (importOriginal) => ({ ...(await importOriginal>()), - terminateDescendantSnapshotAndWait: processWork.terminateDescendantSnapshotAndWait + terminateDescendantSnapshotAndWait: processWork.terminateDescendantSnapshotAndWait, + terminateDescendantSnapshotWithVerdict: processWork.terminateDescendantSnapshotWithVerdict })) vi.mock('../providers/windows-foreground-process-rows', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/provider-process/managed-provider-process-fallback-tree.test.ts b/src/main/provider-process/managed-provider-process-fallback-tree.test.ts new file mode 100644 index 000000000000..4ec32aa9a8bc --- /dev/null +++ b/src/main/provider-process/managed-provider-process-fallback-tree.test.ts @@ -0,0 +1,76 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../shared/child-process/run-process' +import type { DescendantSnapshot } from '../pty-descendant-termination' +import { spawnManagedProviderProcess } from './managed-provider-process' + +// The real fallback teardown; only the primitives that touch the OS are faked. +const os = vi.hoisted(() => ({ + taskkill: vi.fn(async () => {}), + capture: vi.fn(async (): Promise => null), + verifySnapshot: vi.fn(async () => 'exited' as const) +})) +vi.mock('../windows-process-tree-kill', () => ({ terminateWindowsProcessTree: os.taskkill })) +vi.mock('../pty-descendant-termination', () => ({ captureDescendantSnapshot: os.capture })) +vi.mock('../pty-descendant-exit-verification', () => ({ + terminateDescendantSnapshotWithVerdict: os.verifySnapshot +})) +vi.mock('../crash-reporting/self-initiated-tree-kill-log', () => ({ + recordSelfInitiatedTreeKill: vi.fn() +})) + +afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() +}) + +function rootOnly(platform: NodeJS.Platform) { + const child = Object.assign(new EventEmitter(), { + pid: 4242, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + // Only the root dies to SIGKILL; nothing in these paths examines a descendant. + kill: vi.fn((signal?: NodeJS.Signals | number) => { + if (signal === 'SIGKILL') { + queueMicrotask(() => child.emit('exit', null, 'SIGKILL')) + } + return true + }) + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The managed lifecycle reads only events, pid, streams and kill from this fixture. + const spawnImpl = (() => child) as unknown as typeof spawnProcess + return spawnManagedProviderProcess( + { command: 'fixture-provider', args: [] }, + { spawnImpl, platform, site: 'fixture-provider-teardown' } + ) +} + +describe('fallback teardown never claims descendants it did not observe', () => { + it('reports no observation after a Windows tree kill, whose outcome is unreadable', async () => { + vi.useFakeTimers() + const closing = rootOnly('win32').close() + await vi.advanceTimersByTimeAsync(1_500) + await expect(closing).resolves.toEqual({ root: 'exited', tree: null }) + expect(os.taskkill).toHaveBeenCalledOnce() + }) + + it('reports no observation when the POSIX process table cannot be read', async () => { + vi.useFakeTimers() + const closing = rootOnly('darwin').close() + await vi.advanceTimersByTimeAsync(5_500) + await expect(closing).resolves.toEqual({ root: 'exited', tree: null }) + expect(os.capture).toHaveBeenCalledOnce() + expect(os.verifySnapshot).not.toHaveBeenCalled() + }) + + it('reports exited only when the captured descendants were verified gone', async () => { + vi.useFakeTimers() + os.capture.mockResolvedValueOnce({ rootPgid: 1, descendants: [], capturedAtMs: 1 }) + const closing = rootOnly('darwin').close() + await vi.advanceTimersByTimeAsync(5_500) + await expect(closing).resolves.toEqual({ root: 'exited', tree: 'exited' }) + expect(os.verifySnapshot).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/provider-process/managed-provider-process-root-only.test.ts b/src/main/provider-process/managed-provider-process-root-only.test.ts index e4cba9ddc0a4..bb3f407c7dc0 100644 --- a/src/main/provider-process/managed-provider-process-root-only.test.ts +++ b/src/main/provider-process/managed-provider-process-root-only.test.ts @@ -4,8 +4,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { spawnProcess } from '../../shared/child-process/run-process' import { spawnManagedProviderProcess } from './managed-provider-process' import { ROOT_ONLY_GRACEFUL_EXIT_MS } from './provider-process-close' +import type { ProviderProcessTeardownVerdict } from './provider-process-teardown' -const teardown = vi.hoisted(() => ({ terminate: vi.fn(async () => true) })) +const teardown = vi.hoisted(() => ({ + terminate: vi.fn(async (): Promise => 'exited') +})) vi.mock('./provider-process-teardown', () => ({ terminateProviderProcessTree: teardown.terminate })) @@ -47,16 +50,13 @@ describe('root-only managed provider close', () => { expect(teardown.terminate).not.toHaveBeenCalled() }) - it.each([ - [true, 'exited'], - [false, 'unverifiable'] - ] as const)( - 'writes the fallback teardown outcome (accepted=%s) into the tree verdict', - async (accepted, tree) => { + it.each(['exited', 'live', 'unverifiable', null] as const)( + 'writes what the fallback teardown observed (%s) as the tree verdict', + async (tree) => { vi.useFakeTimers() teardown.terminate.mockImplementationOnce(async () => { fixture.child.emit('exit', null, 'SIGKILL') - return accepted + return tree }) const fixture = fakeChild() const managed = rootOnly(fixture) diff --git a/src/main/provider-process/managed-provider-process.ts b/src/main/provider-process/managed-provider-process.ts index 85ce254a0b2b..55b8f59e29f2 100644 --- a/src/main/provider-process/managed-provider-process.ts +++ b/src/main/provider-process/managed-provider-process.ts @@ -5,7 +5,10 @@ import { PROVIDER_SUPERVISOR_MAX_STOP_MS, createProviderSpawnSpec } from './provider-process-supervisor' -import { terminateProviderProcessTree } from './provider-process-teardown' +import { + terminateProviderProcessTree, + type ProviderProcessTeardownVerdict +} from './provider-process-teardown' import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' import { acceptProviderRootExit, @@ -50,7 +53,7 @@ export type ManagedProviderProcess = { /** The last 8 KiB of stderr, which the managed process drains so the child never blocks on it. */ stderrTail(): string onExit(listener: (exit: ProviderProcessExit) => void): void - terminateTree(): Promise + terminateTree(): Promise close(tree?: ProviderProcessTree): Promise } @@ -112,7 +115,7 @@ export function spawnManagedProviderProcess( }) const rootVerdict = (): DescendantTreeVerdict => observed ? 'exited' : child.pid === undefined ? 'unverifiable' : 'live' - const terminateTree = (): Promise => + const terminateTree = (): Promise => terminateProviderProcessTree(child, { site: options.site, platform }) return { diff --git a/src/main/provider-process/provider-process-close.ts b/src/main/provider-process/provider-process-close.ts index fd6f7b289c9a..e67645f2cf23 100644 --- a/src/main/provider-process/provider-process-close.ts +++ b/src/main/provider-process/provider-process-close.ts @@ -2,6 +2,7 @@ import type { SpawnedProcess } from '../../shared/child-process/run-process' import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' import { waitForProcessExitUntil } from './provider-process-exit-deadline' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from './provider-process-supervisor' +import type { ProviderProcessTeardownVerdict } from './provider-process-teardown' export type ProviderProcessTree = { capture(): Promise @@ -23,7 +24,7 @@ export type ProviderProcessCloseInput = { supervised?: boolean policy: ProviderProcessClosePolicy tree?: ProviderProcessTree - terminateTree: () => Promise + terminateTree: () => Promise } export type ProviderProcessCloseResult = { @@ -74,7 +75,7 @@ export async function closeProviderProcess( if (tree) { await tree.reap() } else { - fallbackTree = (await input.terminateTree()) ? 'exited' : 'unverifiable' + fallbackTree = await input.terminateTree() } await waitForProcessExitUntil(input.exitPromise, policy.forcedExitMs) } diff --git a/src/main/provider-process/provider-process-teardown.test.ts b/src/main/provider-process/provider-process-teardown.test.ts index d5fdcce6afcf..8bf93efac3c6 100644 --- a/src/main/provider-process/provider-process-teardown.test.ts +++ b/src/main/provider-process/provider-process-teardown.test.ts @@ -4,6 +4,7 @@ import { findSelfInitiatedTreeKills, resetSelfInitiatedTreeKillLogForTest } from '../crash-reporting/self-initiated-tree-kill-log' +import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' import { terminateProviderProcessTree } from './provider-process-teardown' /** Above pid_max on every supported POSIX host, so the group signal is a real ESRCH. */ @@ -21,7 +22,7 @@ describe('terminateProviderProcessTree', () => { resetSelfInitiatedTreeKillLogForTest() }) - it('waits for the Windows tree kill before releasing the wrapper', async () => { + it('waits for the Windows tree kill before releasing the wrapper, and claims no observation', async () => { const target = child() const release = Promise.withResolvers() const terminateWindowsTree = vi.fn(() => release.promise) @@ -33,7 +34,8 @@ describe('terminateProviderProcessTree', () => { }) expect(target.kill).not.toHaveBeenCalled() release.resolve() - await teardown + // taskkill resolves alike on success, failure and timeout. + await expect(teardown).resolves.toBeNull() expect(terminateWindowsTree).toHaveBeenCalledWith(1234, { site: 'codex-app-server-teardown' }) expect(target.kill).toHaveBeenCalledWith('SIGKILL') @@ -54,7 +56,7 @@ describe('terminateProviderProcessTree', () => { it('waits for an owned POSIX snapshot before killing the wrapper', async () => { const target = child() const snapshot = { rootPgid: 1234, descendants: [], capturedAtMs: 1 } - const release = Promise.withResolvers() + const release = Promise.withResolvers() const teardown = terminateProviderProcessTree(target, { site: 'codex-app-server-teardown', @@ -64,8 +66,8 @@ describe('terminateProviderProcessTree', () => { }) await vi.waitFor(() => expect(target.kill).toHaveBeenCalledWith('SIGSTOP')) expect(target.kill).not.toHaveBeenCalledWith('SIGKILL') - release.resolve(true) - await teardown + release.resolve('exited') + await expect(teardown).resolves.toBe('exited') expect(target.kill).toHaveBeenLastCalledWith('SIGKILL') }) @@ -83,7 +85,7 @@ describe('terminateProviderProcessTree', () => { captureDescendants, signalProcessGroup }) - ).resolves.toBe(true) + ).resolves.toBeNull() expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL') expect(captureDescendants).not.toHaveBeenCalled() @@ -105,7 +107,7 @@ describe('terminateProviderProcessTree', () => { throw Object.assign(new Error('denied'), { code: 'EPERM' }) } }) - ).resolves.toBe(false) + ).resolves.toBe('unverifiable') expect(target.kill).not.toHaveBeenCalled() }) @@ -129,9 +131,9 @@ describe('terminateProviderProcessTree', () => { descendants: [], capturedAtMs: 1 }), - terminateDescendants: async () => true + terminateDescendants: async () => 'exited' }) - ).resolves.toBe(true) + ).resolves.toBe('exited') expect(target.kill).toHaveBeenLastCalledWith('SIGKILL') expect(findSelfInitiatedTreeKills(Date.now())).toEqual([]) @@ -146,10 +148,10 @@ describe('terminateProviderProcessTree', () => { site: 'codex-app-server-teardown', platform: 'darwin', captureDescendants: async () => ({ rootPgid: 1234, descendants: [], capturedAtMs: 1 }), - terminateDescendants: async () => true, + terminateDescendants: async () => 'exited', signalProcessGroup }) - ).resolves.toBe(true) + ).resolves.toBe('exited') expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL') expect(findSelfInitiatedTreeKills(Date.now())).toEqual([ @@ -161,6 +163,54 @@ describe('terminateProviderProcessTree', () => { ]) }) + it.each([ + ['live', 'live'], + ['unverifiable', 'unverifiable'] + ] as const)( + 'reports a %s descendant snapshot as-is and leaves the stopped root resumable', + async (observed, verdict) => { + const target = child() + await expect( + terminateProviderProcessTree(target, { + site: 'codex-app-server-teardown', + platform: 'darwin', + captureDescendants: async () => ({ rootPgid: 1234, descendants: [], capturedAtMs: 1 }), + terminateDescendants: async () => observed + }) + ).resolves.toBe(verdict) + expect(target.kill).toHaveBeenLastCalledWith('SIGCONT') + } + ) + + it('claims no observation when the POSIX process table cannot be read', async () => { + const target = child() + await expect( + terminateProviderProcessTree(target, { + site: 'codex-app-server-teardown', + platform: 'darwin', + captureDescendants: async () => null + }) + ).resolves.toBeNull() + expect(target.kill).toHaveBeenLastCalledWith('SIGKILL') + }) + + it('reports an ESRCH dedicated group as exited and a spawnless child as unverifiable', async () => { + await expect( + terminateProviderProcessTree(child(), { + site: 'provider-test-teardown', + platform: 'linux', + dedicatedProcessGroup: true, + signalProcessGroup: () => { + throw Object.assign(new Error('gone'), { code: 'ESRCH' }) + } + }) + ).resolves.toBe('exited') + const spawnless = { pid: undefined, kill: vi.fn(() => true) } + await expect( + terminateProviderProcessTree(spawnless, { site: 'provider-test-teardown', platform: 'linux' }) + ).resolves.toBe('unverifiable') + }) + it('tears down 40 dedicated groups without process-table scans or cross-group fanout', async () => { const killMocks = Array.from({ length: 40 }, () => vi.fn(() => true)) const targets = killMocks.map((kill, index) => ({ @@ -182,7 +232,7 @@ describe('terminateProviderProcessTree', () => { ) ) - expect(results).toEqual(Array.from({ length: targets.length }, () => true)) + expect(results).toEqual(Array.from({ length: targets.length }, () => null)) expect(signalProcessGroup.mock.calls).toEqual(targets.map((target) => [target.pid, 'SIGKILL'])) expect(captureDescendants).not.toHaveBeenCalled() expect(killMocks.every((kill) => kill.mock.calls.length === 0)).toBe(true) diff --git a/src/main/provider-process/provider-process-teardown.ts b/src/main/provider-process/provider-process-teardown.ts index 19e5b9b967f3..a8b944cab998 100644 --- a/src/main/provider-process/provider-process-teardown.ts +++ b/src/main/provider-process/provider-process-teardown.ts @@ -1,10 +1,16 @@ import type { ChildProcessHandle } from '../../shared/child-process/run-process' import { captureDescendantSnapshot, type DescendantSnapshot } from '../pty-descendant-termination' -import { terminateDescendantSnapshotAndWait } from '../pty-descendant-exit-verification' +import { + terminateDescendantSnapshotWithVerdict, + type DescendantTreeVerdict +} from '../pty-descendant-exit-verification' import { terminateWindowsProcessTree } from '../windows-process-tree-kill' import { recordSelfInitiatedTreeKill } from '../crash-reporting/self-initiated-tree-kill-log' -const activeTeardowns = new WeakMap>() +/** What the teardown observed of the descendants; null when it signalled but observed nothing. */ +export type ProviderProcessTeardownVerdict = DescendantTreeVerdict | null + +const activeTeardowns = new WeakMap>() type TeardownChild = Pick @@ -13,20 +19,24 @@ export type ProviderProcessTeardownDeps = { platform?: NodeJS.Platform dedicatedProcessGroup?: boolean captureDescendants?: (rootPid: number) => Promise - terminateDescendants?: (snapshot: DescendantSnapshot) => Promise + terminateDescendants?: (snapshot: DescendantSnapshot) => Promise terminateWindowsTree?: (rootPid: number, deps?: { site?: string }) => Promise signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void } -function terminateDedicatedPosixGroup(rootPid: number, deps: ProviderProcessTeardownDeps): boolean { +function terminateDedicatedPosixGroup( + rootPid: number, + deps: ProviderProcessTeardownDeps +): ProviderProcessTeardownVerdict { const signalGroup = deps.signalProcessGroup ?? ((pgid: number, signal: NodeJS.Signals) => process.kill(-pgid, signal)) try { signalGroup(rootPid, 'SIGKILL') } catch (error) { + // ESRCH is the kernel reporting no process left in the group: positive evidence of absence. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Node process.kill errors expose an optional errno code; only that field is read. - return (error as NodeJS.ErrnoException).code === 'ESRCH' + return (error as NodeJS.ErrnoException).code === 'ESRCH' ? 'exited' : 'unverifiable' } // Outside the try: that catch is the ESRCH contract, not a breadcrumb handler. recordSelfInitiatedTreeKill({ @@ -34,23 +44,28 @@ function terminateDedicatedPosixGroup(rootPid: number, deps: ProviderProcessTear site: deps.site, scope: 'posix-process-group' }) - return true + // A delivered signal is not an observed exit. + return null } async function terminatePosixTree( child: TeardownChild, rootPid: number, deps: ProviderProcessTeardownDeps -): Promise { +): Promise { child.kill('SIGSTOP') const capture = deps.captureDescendants ?? captureDescendantSnapshot const snapshot = await capture(rootPid).catch(() => null) if (!snapshot) { + // No readable table, so no descendant was seen either way. child.kill('SIGKILL') - return true + return null } - const terminate = deps.terminateDescendants ?? terminateDescendantSnapshotAndWait - const descendantsExited = await terminate(snapshot) + const terminate = + deps.terminateDescendants ?? + ((captured: DescendantSnapshot) => terminateDescendantSnapshotWithVerdict(captured)) + const verdict = await terminate(snapshot) + const descendantsExited = verdict === 'exited' // A detached POSIX launch is the leader of its own process group. Group // signalling reaches grandchildren even after they daemonise/reparent, // while the stopped root and captured pgid make the ownership proof exact. @@ -80,28 +95,29 @@ async function terminatePosixTree( } if (!descendantsExited) { child.kill('SIGCONT') - return false + return verdict } child.kill('SIGKILL') - return true + return 'exited' } /** Stops every process owned by one provider launch before releasing its wrapper. */ async function terminateOnce( child: TeardownChild, deps: ProviderProcessTeardownDeps -): Promise { +): Promise { const rootPid = child.pid if (!rootPid) { child.kill('SIGKILL') - return false + return 'unverifiable' } if ((deps.platform ?? process.platform) === 'win32') { const terminate = deps.terminateWindowsTree ?? terminateWindowsProcessTree await terminate(rootPid, { site: deps.site }) // taskkill owns the tree; this preserves the prior direct-child fallback when it fails. child.kill('SIGKILL') - return true + // taskkill resolves alike on success, failure and timeout, so nothing was observed. + return null } if (deps.dedicatedProcessGroup) { return terminateDedicatedPosixGroup(rootPid, deps) @@ -112,13 +128,15 @@ async function terminateOnce( export function terminateProviderProcessTree( child: TeardownChild, deps: ProviderProcessTeardownDeps -): Promise { +): Promise { const key = child const active = activeTeardowns.get(key) if (active) { return active } - const attempt = terminateOnce(child, deps).catch(() => false) + const attempt = terminateOnce(child, deps).catch( + (): ProviderProcessTeardownVerdict => 'unverifiable' + ) activeTeardowns.set(key, attempt) void attempt.then(() => { if (activeTeardowns.get(key) === attempt) { From a4df9df2f38d307492628a3d441b5c1657140da6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:32:46 -0700 Subject: [PATCH 124/269] Run a transition's steps as a prefix; drop the paced flag and resolved options A failed step no longer lets the steps after it write: each step checks, at its own turn in the journal's queue, whether the write handed over just ahead of it completed, using the queue's count of completed write bodies (a promise would report the failure only after the next step ran). The sink fails only once every step has had its turn. The item step's `paced` size bypass and the resolver's replacement `options` are removed; nothing planned uses them. --- .../journal-item-appender.ts | 9 +- .../agent-session-journal/journal-store.ts | 5 + .../journal-write-queue.ts | 16 ++- ...tructured-agent-session-transition.test.ts | 106 +++++++++++++----- .../structured-agent-session-transition.ts | 78 ++++++++----- 5 files changed, 152 insertions(+), 62 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-item-appender.ts b/src/main/native-chat/agent-session-journal/journal-item-appender.ts index 479f0bc04a9d..146078d830c9 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-appender.ts @@ -10,12 +10,7 @@ import type { JournalRow } from './journal-row-schema' const NOTHING_RESOLVED = new Error('journal_item_resolved_to_nothing') -export type JournalResolvedItem = { - identity: AgentJournalItemIdentity - body: AgentJournalItemBody - /** Replaces the call's options when only the fold could say where the row belongs. */ - options?: JournalItemAppendOptions -} +export type JournalResolvedItem = { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } export class JournalItemAppender { constructor( @@ -53,7 +48,7 @@ export class JournalItemAppender { this.deps.state, resolved.identity, resolved.body, - resolved.options ?? options + options )(seq, ts) }) .then( diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index d12257447cfd..3cabb7bd53ef 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -208,6 +208,11 @@ export class AgentSessionJournal { return this.queue.owing } + /** Write bodies that returned so far; see `JournalWriteQueue.completedWrites`. */ + get completedWrites(): number { + return this.queue.completedWrites + } + cursor = (): AgentJournalCursor => ({ epoch: this.state.epoch, sequence: this.state.lastSequence diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.ts index 74ef33472bd4..ae4f5c96d893 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.ts @@ -30,6 +30,7 @@ export class JournalWriteQueue { private closed = false /** Runs before the next write, and stays owed until it succeeds. */ private owed: (() => Promise) | null = null + private completed = 0 constructor(private readonly sessionId: string) {} @@ -41,9 +42,20 @@ export class JournalWriteQueue { if (this.closed) { return Promise.reject(this.closedError()) } + const counted = (): JournalWriteResult => { + const result = run() + this.completed++ + return result + } return this.owed !== null || this.lineBusy - ? this.join(run, this.owed !== null) - : this.runNow(run) + ? this.join(counted, this.owed !== null) + : this.runNow(counted) + } + + /** Write bodies that returned without throwing. Bodies run one at a time, so a body that reads + * this learns synchronously whether the write just ahead of it landed. */ + get completedWrites(): number { + return this.completed } /** Runs `read` after every write admitted before it, whatever each one's outcome, and ahead of any diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts index f752a2147afc..b4232642dee0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -52,11 +52,12 @@ function failedTool(id: AgentJournalItemIdentity): JournalLifecycleMutationInput } function itemStep( - resolve: Extract['resolve'] + resolve: Extract['resolve'], + reservedBytes = 4096 ): StructuredAgentSessionTransitionStep { return { kind: 'item', - reservedBytes: 4096, + reservedBytes, resolve, options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE } } @@ -121,29 +122,6 @@ describe('structured agent-session transitions', () => { expect(publishes).toHaveLength(1) }) - it('places a row where its resolver says', async () => { - const { journal, deferred, sink, bind } = await rig() - bind() - const scope = { kind: 'turn', turnItemId: 'turn-row' } as const - sink.tryAppendTransition?.({ - lifecycle: false, - publish: true, - steps: [ - itemStep(() => ({ - identity: identity('placed'), - body: tool('read', 'running'), - options: { turnScope: scope } - })), - itemStep(() => null) - ] - }) - await deferred.drained() - - expect(journal.item(agentJournalItemKey(identity('placed')))).toMatchObject({ - turnScope: scope - }) - }) - it('admitted whole is not executed whole: a failed step keeps the ones before it and fails the sink', async () => { const { journal, deferred, sink, publishes, bind } = await rig() bind() @@ -172,6 +150,82 @@ describe('structured agent-session transitions', () => { ).toEqual({ accepted: false, reason: 'failed' }) }) + it('writes nothing after a step that overflows its reservation', async () => { + const { journal, deferred, sink, publishes, bind } = await rig() + bind() + const after = vi.fn(() => ({ identity: identity('third'), body: tool('read', 'running') })) + sink.tryAppendTransition?.({ + lifecycle: false, + publish: true, + steps: [ + itemStep(() => ({ identity: identity('first'), body: tool('read', 'running') })), + itemStep( + () => ({ identity: identity('second'), body: tool('x'.repeat(10_000), 'running') }), + 64 + ), + itemStep(after) + ] + }) + + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalItemKey(identity('first')) + ]) + expect(after).not.toHaveBeenCalled() + expect(publishes).toEqual([]) + }) + + it('opens no next-turn work after a settlement the journal refused', async () => { + const { journal, deferred, sink, bind } = await rig() + bind() + const old = identity('old-tool') + sink.tryAppendItem?.(old, tool('read', 'running'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + await deferred.drained() + sink.tryAppendTransition?.({ + lifecycle: true, + publish: true, + steps: [ + itemStep(() => ({ identity: identity('before'), body: tool('read', 'completed') })), + // Names one item twice, which the journal refuses. + { + kind: 'settlement', + settlementId: 'settle', + reservedBytes: 1, + resolve: () => [failedTool(old), failedTool(old)] + }, + itemStep(() => ({ identity: identity('next-turn-work'), body: tool('read', 'running') })) + ] + }) + + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalItemKey(old), + agentJournalItemKey(identity('before')) + ]) + expect(journal.item(agentJournalItemKey(old))?.body).toMatchObject({ state: 'running' }) + }) + + it('writes nothing after a step whose row the database refused', async () => { + const { root, journal, deferred, sink, bind } = await rig() + bind() + // Refuses only the second step's row, so the third would land in its place. + openTestJournalHostDatabase(root).db.exec(`CREATE TEMP TRIGGER fail_second_step +BEFORE INSERT ON main.journal_rows WHEN instr(NEW.row_json, 'refused-step') > 0 +BEGIN SELECT RAISE(ABORT, 'second step refused'); END`) + const step = (recordId: string) => + itemStep(() => ({ identity: identity(recordId), body: tool(recordId, 'running') })) + sink.tryAppendTransition?.({ + lifecycle: false, + publish: true, + steps: [step('first'), step('refused-step'), step('third')] + }) + + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalItemKey(identity('first')) + ]) + }) + it('refuses a transition whole, so none of its steps ever lands', async () => { const { journal, deferred, sink, bind } = await rig({ maxQueuedOperations: 1 }) const step = (recordId: string) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts index a185695d5ea5..57c0c7d0f5e8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -6,8 +6,8 @@ // all. At execution its steps are issued in the same tick, so they sit together in the journal's // write queue, and each step resolves against the fold with every earlier write landed — the // steps before it included — so what a step writes is decided by the journal, not by memory. -// Admitted whole is not executed whole: a step that fails leaves the steps before it written, and -// fails the sink, so nothing more is admitted after it. +// Admitted whole, executed as a prefix: once a step fails, the steps after it write nothing, the +// steps before it stay written, and the sink fails, so nothing more is admitted after it. import type { AgentJournalItemBody, @@ -32,16 +32,12 @@ export type StructuredAgentSessionTransitionJournal = Pick< export type StructuredAgentSessionTransitionStep = | { kind: 'item' - /** Bounds what `resolve` may write; a larger write fails the sink, unless `paced`. */ + /** Bounds what `resolve` may write; a larger write fails the sink. */ reservedBytes: number - /** The size only paces the queue: the row grows by text the fold holds (a resumed message). */ - paced?: true - /** The row and its whole body; null writes nothing. Resolved `options` replace the planned - * ones, for a writer that learns the row's turn or producer only from the fold. */ + /** The row and its whole body; null writes nothing. */ resolve: (journal: StructuredAgentSessionTransitionJournal) => { identity: AgentJournalItemIdentity body: AgentJournalItemBody - options?: StructuredAgentSessionItemAppendOptions } | null options: StructuredAgentSessionItemAppendOptions } @@ -94,35 +90,25 @@ function transitionAppend( lifecycle: transition.lifecycle, run: async (bound) => { const { journal, fence } = bound + const gate = createStepGate(journal) // Issued in one tick, so no write submitted after this transition lands between its steps. - const writes = transition.steps.map((step) => + const writes = transition.steps.map((step, index) => step.kind === 'item' ? journal .appendResolvedItem( () => { - const resolved = step.resolve(journal) + const resolved = gate.open(index) ? step.resolve(journal) : null if (!resolved) { + gate.wroteNothing() return null } if ( - !step.paced && estimateStructuredAgentSessionItemBytes(resolved.identity, resolved.body) > - step.reservedBytes + step.reservedBytes ) { throw new Error(STEP_OVERFLOW) } - return { - identity: resolved.identity, - body: resolved.body, - ...(resolved.options - ? { - options: structuredAgentSessionJournalAppendOptions( - fence, - resolved.options - ) - } - : {}) - } + return resolved }, structuredAgentSessionJournalAppendOptions(fence, step.options) ) @@ -130,15 +116,53 @@ function transitionAppend( : journal .appendResolvedLifecycleBatch({ settlementId: step.settlementId, - resolve: () => step.resolve(journal), + resolve: () => (gate.open(index) ? step.resolve(journal) : []), fence }) .then((landed) => landed !== null) ) - const wrote = await Promise.all(writes) - if (transition.publish && wrote.some(Boolean)) { + // Every step, the ones a failure turned into no-ops included, has run before this settles. + const outcomes = await Promise.allSettled(writes) + const failure = outcomes.find( + (outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected' + ) + if (failure) { + throw failure.reason + } + if ( + transition.publish && + outcomes.some((outcome) => outcome.status === 'fulfilled' && outcome.value) + ) { bound.publish() } } }) } + +/** Lets a step write only when every step before it landed. Steps run in queue order, so each + * step's write learns synchronously, from the journal's count of completed writes, whether the + * write handed over just ahead of it landed; a promise would say so only after it ran. */ +function createStepGate(journal: Pick): { + /** First thing step `index` does at its turn; false once an earlier step failed. */ + open: (index: number) => boolean + /** The step writes nothing, so whether its write completes says nothing about a failure. */ + wroteNothing: () => void +} { + let failed = false + let reached = 0 + let handedAt: number | null = null + return { + open: (index) => { + // A step that never reached its resolve failed before it: a refused import, a read-only journal. + if (reached !== index || (handedAt !== null && journal.completedWrites === handedAt)) { + failed = true + } + reached = index + 1 + handedAt = failed ? null : journal.completedWrites + return !failed + }, + wroteNothing: () => { + handedAt = null + } + } +} From eb94d9b16158133a9bca831b0211e86de3e482da Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:32:58 -0700 Subject: [PATCH 125/269] Give two test fixtures' casts a SAFETY rationale for the changed-code gate --- src/main/claude/claude-agent-sdk-exit-proof.test.ts | 7 ++++--- .../codex-app-server-connection-tree-unproven.test.ts | 11 +++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/main/claude/claude-agent-sdk-exit-proof.test.ts b/src/main/claude/claude-agent-sdk-exit-proof.test.ts index 675ffc113b43..e27e74046d60 100644 --- a/src/main/claude/claude-agent-sdk-exit-proof.test.ts +++ b/src/main/claude/claude-agent-sdk-exit-proof.test.ts @@ -120,13 +120,14 @@ function mockChild( pid: number | null = 424242 ): EventEmitter & Pick & { kill: ReturnType } { - const child = new EventEmitter() - return Object.assign(child, { + const child = Object.assign(new EventEmitter(), { pid: pid ?? undefined, stdin: new PassThrough(), stderr: new PassThrough(), kill: vi.fn(() => true) - }) as never + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The proof reads only pid, events, kill, stdin and stderr from this fixture. + return child as never } /** A tree whose verdict is scripted per reap, recording when it was armed. */ diff --git a/src/main/codex/codex-app-server-connection-tree-unproven.test.ts b/src/main/codex/codex-app-server-connection-tree-unproven.test.ts index 82e7b03dab13..0e0cca283e87 100644 --- a/src/main/codex/codex-app-server-connection-tree-unproven.test.ts +++ b/src/main/codex/codex-app-server-connection-tree-unproven.test.ts @@ -5,10 +5,13 @@ import type { spawnProcess } from '../../shared/child-process/run-process' import type { ProviderProcessTeardownVerdict } from '../provider-process/provider-process-teardown' import { openCodexAppServerConnection } from './codex-app-server-connection' -const teardown = vi.hoisted(() => ({ - verdict: null as ProviderProcessTeardownVerdict, - rootExits: (): void => {} -})) +const teardown = vi.hoisted(() => { + const state: { verdict: ProviderProcessTeardownVerdict; rootExits: () => void } = { + verdict: null, + rootExits: () => {} + } + return state +}) vi.mock('../provider-process/provider-process-teardown', () => ({ terminateProviderProcessTree: vi.fn(async () => { teardown.rootExits() From 2faf837f84b0fd141d43a184d828e2c740bd8cc7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:36:28 -0700 Subject: [PATCH 126/269] Rebuild the timeline assembler on one admission-order state The assembler kept a second copy of its state (a forecast beside a ledger), hydrated the open turn from the journal, and recognised replays, all to recover from losing its memory while the provider child kept streaming. That never happens: one assembler lives exactly as long as one child, and a new child is a new assembler in a new generation whose events land after the dead-generation sweep. - One state, changed only when the sink admits an event (minted keys included), so a refused event takes nothing. - Every journal-dependent choice is made when the write runs, by keyed reads: a turn row is written only where none is, a stream checks its row's turn on every write, a running snapshot never lands in a settled turn or relights a settled tool, a request takes the first incarnation the journal holds no row for. - Rows are found by spelling their ids (provider-timeline-rows.ts); no join cache. - The identity scheme (legacy arm only) lives here with its first caller; requests are spelled in their acquisition generation, since JSON-RPC ids restart per process. - Saved history goes in as `input.history` plus ordinary events with the provider's ids, into an empty journal; `session.reset` and every replay rule are gone. - One terminal-body function (`terminalAgentJournalBody`) is shared with the dead-generation settlement. - The test rig's restart now sweeps and starts a new generation, as production does. --- .../codex-provider-timeline-identity.test.ts | 409 ------------------ .../journal-terminal-settlement.ts | 13 + .../provider-timeline-apply-host.ts | 35 -- ...der-timeline-assembler-attribution.test.ts | 98 ++--- ...vider-timeline-assembler-held-rows.test.ts | 160 +++++++ ...ovider-timeline-assembler-recovery.test.ts | 402 ----------------- ...rovider-timeline-assembler-refusal.test.ts | 25 +- ...provider-timeline-assembler-replay.test.ts | 295 ------------- ...rovider-timeline-assembler-restart.test.ts | 153 +++++++ .../provider-timeline-assembler-rows.test.ts | 207 +++++++++ ...rovider-timeline-assembler-session.test.ts | 11 +- ...rovider-timeline-assembler-streams.test.ts | 107 +---- ...rovider-timeline-assembler-test-support.ts | 56 ++- .../provider-timeline-assembler-turns.test.ts | 20 +- .../provider-timeline-assembler.ts | 214 +++------ .../provider-timeline-budget.ts | 39 +- .../provider-timeline-context.ts | 41 +- .../provider-timeline-decision.ts | 125 +++--- .../provider-timeline-decisions.ts | 76 ---- .../provider-timeline-event.ts | 36 +- .../provider-timeline-identity.test.ts | 54 +++ .../provider-timeline-identity.ts | 106 +++++ .../provider-timeline-item-decisions.ts | 291 +++++++------ .../provider-timeline-plan.ts | 78 +--- .../provider-timeline-rows.ts | 110 +++++ .../provider-timeline-settlement.ts | 27 +- .../provider-timeline-state.ts | 204 +++------ .../provider-timeline-stream-rows.ts | 185 -------- .../provider-timeline-text-events.ts | 100 +++-- .../provider-timeline-text-streams.ts | 211 +++------ .../provider-timeline-transition-layout.ts | 127 ++---- .../provider-timeline-turn-decisions.ts | 302 +++++++------ ...gent-session-dead-generation-settlement.ts | 19 +- .../structured-agent-session-transition.ts | 7 +- 34 files changed, 1684 insertions(+), 2659 deletions(-) delete mode 100644 src/main/codex/codex-provider-timeline-identity.test.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-held-rows.test.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-rows.test.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-identity.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts diff --git a/src/main/codex/codex-provider-timeline-identity.test.ts b/src/main/codex/codex-provider-timeline-identity.test.ts deleted file mode 100644 index 84fc68f15bf3..000000000000 --- a/src/main/codex/codex-provider-timeline-identity.test.ts +++ /dev/null @@ -1,409 +0,0 @@ -// The migration contract for moving Codex onto the shared timeline assembler: the assembler with -// the Codex identity scheme lands the same rows Codex's own translator lands today — the same keys -// (live messages, history restored on resume, a late item naming its own turn, interleaved -// subagent threads) and the same row contents. - -import { afterEach, describe, expect, it } from 'vitest' -import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' -import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' -import { - closeProviderTimelineRigs, - openProviderTimelineRig, - SESSION -} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { ProviderTurnMessageOrdinals } from '../native-chat/agent-session-timeline/provider-turn-message-ordinals' -import { createCodexProviderTimelineIdentityScheme } from './codex-provider-timeline-identity' -import { - codexItemIdentity, - codexJournalItem, - type CodexThreadItem -} from './codex-structured-item-translation' -import { createCodexJournalTranslator } from './codex-structured-journal-translation' -import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' - -afterEach(closeProviderTimelineRigs) - -const THREAD = 'thread-abc' -const CHILD = 'thread-child' - -async function codexRig() { - return openProviderTimelineRig({ - scheme: createCodexProviderTimelineIdentityScheme({ - sessionId: SESSION, - primaryThreadId: () => THREAD - }), - ownThread: () => THREAD - }) -} - -function body(item: CodexThreadItem, started?: CodexThreadItem) { - const translated = codexJournalItem(item, undefined, started).body - if ( - !translated || - translated.kind === 'turn' || - translated.kind === 'approval' || - translated.kind === 'question' - ) { - throw new Error(`no item body for ${item.type}`) - } - return translated -} - -const keys = (rows: AgentJournalRenderItem[]) => - rows.filter((row) => row.body.kind !== 'turn').map((row) => row.itemId) - -/** What a client renders from a row; sequence, revision and receipt time are the journal's own. */ -const content = (rows: AgentJournalRenderItem[]) => - JSON.stringify( - rows.map( - ({ - itemId, - body, - turnScope, - agentId, - parentAgentId, - providerParentRef, - producerKind, - attempt - }) => ({ - itemId, - body, - turnScope, - agentId, - parentAgentId, - providerParentRef, - producerKind, - attempt - }) - ) - ) - -function liveTranslator(rig: Awaited>) { - return createCodexJournalTranslator({ - sink: rig.eventSink, - sessionId: SESSION, - primaryThreadId: () => THREAD, - now: () => 1_000, - schedule: (run) => { - run() - return () => {} - } - }) -} - -function notify(method: string, params: unknown): CodexStructuredSessionEvent { - return { type: 'notification', sessionId: SESSION, threadId: THREAD, method, params } -} - -describe('Codex identities through the shared assembler', () => { - it('keys messages by turn and message ordinal, and a message outside any turn by its item id', async () => { - const rig = await codexRig() - const message = ( - id: string, - text: string, - join?: { turn?: string } - ): ProviderTimelineEvent => ({ - type: 'item.close', - item: id, - body: body({ type: 'agentMessage', id, text }), - join: { thread: THREAD, ...join } - }) - rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) - rig.assembler.apply(message('item-0', 'one')) - rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 2_000, state: 'completed' }) - rig.assembler.apply(message('item-1', 'orphan')) - rig.assembler.apply({ type: 'turn.open', turn: 'turn-2', at: 3_000 }) - rig.assembler.apply(message('item-2', 'two')) - // A message naming another turn joins that turn, whatever is open. - rig.assembler.apply(message('item-3', 'late', { turn: 'turn-9' })) - - // The keys Codex's own translator writes for the same frames. - expect(keys(await rig.rows())).toEqual([ - 'codex:thread-abc:turn-1:0', - 'orca:codex-item%3Athread-abc%3Aitem-1', - 'codex:thread-abc:turn-2:0', - 'codex:thread-abc:turn-9:0' - ]) - }) - - it('keeps interleaved subagent threads apart while their rows join the open turn', async () => { - const rig = await codexRig() - rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) - rig.assembler.apply({ - type: 'item.close', - item: 'item-0', - body: body({ type: 'agentMessage', id: 'item-0', text: 'root' }), - join: { thread: THREAD } - }) - rig.assembler.apply({ - type: 'item.close', - item: 'item-0', - body: body({ type: 'agentMessage', id: 'item-0', text: 'child' }), - join: { thread: CHILD, turn: 'turn-child' } - }) - rig.assembler.apply({ - type: 'item.close', - item: 'item-1', - body: body({ type: 'agentMessage', id: 'item-1', text: 'still root' }), - join: { thread: THREAD } - }) - const rows = (await rig.rows()).filter((row) => row.body.kind !== 'turn') - expect(rows.map((row) => row.itemId)).toEqual([ - 'codex:thread-abc:turn-1:0', - 'codex:thread-child:turn-child:0', - 'codex:thread-abc:turn-1:1' - ]) - expect(new Set(rows.map((row) => JSON.stringify(row.turnScope))).size).toBe(1) - }) - - it('counts an echoed send in its turn’s message ordinals, as resume history does', async () => { - const rig = await codexRig() - const ordinals = new ProviderTurnMessageOrdinals() - const oracle = (item: CodexThreadItem) => - agentJournalItemKey(codexItemIdentity({ threadId: THREAD, turnId: 'turn-1', item, ordinals })) - const echo: CodexThreadItem = { type: 'userMessage', id: 'item-0', content: [] } - const reasoning: CodexThreadItem = { type: 'reasoning', id: 'item-1', summary: ['why'] } - const answer: CodexThreadItem = { type: 'agentMessage', id: 'item-2', text: 'answer' } - const expected = [oracle(echo), oracle(reasoning), oracle(answer)] - - rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) - rig.assembler.apply({ - type: 'input.accepted', - clientMessageId: 'send-1', - requestedAt: 900, - join: { thread: THREAD, turn: 'turn-1', item: 'item-0' } - }) - rig.assembler.apply({ - type: 'text.delta', - item: { id: 'item-1' }, - channel: 'reasoning', - text: 'why', - join: { thread: THREAD } - }) - rig.assembler.apply({ - type: 'item.close', - item: 'item-2', - body: body(answer), - join: { thread: THREAD } - }) - rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 2_000, state: 'completed' }) - - expect(keys(await rig.rows())).toEqual(expected.slice(1)) - expect(expected[2]).toBe('codex:thread-abc:turn-1:1') - }) -}) - -describe('Codex rows through the shared assembler', () => { - it('lands the same rows as the Codex translator for a live turn', async () => { - const codex = await openProviderTimelineRig() - let clock = 1_000 - const translator = createCodexJournalTranslator({ - sink: codex.eventSink, - sessionId: SESSION, - primaryThreadId: () => THREAD, - now: () => clock, - schedule: (run) => { - run() - return () => {} - } - }) - const notify = (method: string, params: unknown): CodexStructuredSessionEvent => ({ - type: 'notification', - sessionId: SESSION, - threadId: THREAD, - method, - params - }) - const started: CodexThreadItem = { - type: 'commandExecution', - id: 'exec-1', - command: 'ls', - status: 'inProgress' - } - const finished: CodexThreadItem = { - ...started, - status: 'completed', - exitCode: 0, - aggregatedOutput: 'ok' - } - const reply: CodexThreadItem = { type: 'agentMessage', id: 'item-1', text: 'Hello' } - translator.handle(notify('turn/started', { turn: { id: 'turn-1' } })) - translator.handle(notify('item/started', { item: { ...reply, text: '' } })) - translator.handle(notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Hello' })) - translator.handle(notify('item/completed', { item: reply })) - translator.handle(notify('item/started', { item: started })) - translator.handle(notify('item/completed', { item: finished })) - clock = 2_000 - translator.handle( - notify('turn/completed', { turn: { id: 'turn-1', status: 'completed', durationMs: 900 } }) - ) - - const shared = await codexRig() - const join = { thread: THREAD, turn: 'turn-1' } - shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) - shared.assembler.apply({ - type: 'text.delta', - item: { id: 'item-1' }, - channel: 'assistant', - text: 'Hello', - join - }) - shared.assembler.apply({ type: 'item.close', item: 'item-1', body: body(reply), join }) - shared.assembler.apply({ type: 'item.open', item: 'exec-1', body: body(started), join }) - shared.assembler.apply({ - type: 'item.close', - item: 'exec-1', - body: body(finished, started), - join - }) - shared.assembler.apply({ - type: 'turn.end', - turn: 'turn-1', - at: 2_000, - state: 'completed', - outcome: 'success', - durationMs: 900 - }) - - expect(content(await shared.rows())).toBe(content(await codex.rows())) - }) - - it('lands the same rows as the Codex translator for a turn restored from history', async () => { - const turn = { - id: 'turn-1', - status: 'completed', - startedAt: 1_700_000_000, - completedAt: 1_700_000_050, - items: [ - { type: 'userMessage', id: 'user-1', content: [{ type: 'text', text: 'ask' }] }, - { type: 'reasoning', id: 'think-1', summary: ['why'] }, - { type: 'agentMessage', id: 'interim-1', text: 'looking' }, - { type: 'commandExecution', id: 'exec-1', command: 'ls', status: 'completed', exitCode: 0 }, - { type: 'agentMessage', id: 'answer-1', text: 'answer' } - ] - } - const codex = await openProviderTimelineRig() - const translator = createCodexJournalTranslator({ - sink: codex.eventSink, - sessionId: SESSION, - primaryThreadId: () => THREAD - }) - expect(translator.restoreThread(THREAD, { turns: [turn] })).toEqual({ accepted: true }) - - const shared = await codexRig() - const join = { thread: THREAD, turn: 'turn-1' } - shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: turn.startedAt * 1000 }) - for (const item of turn.items) { - const translated = codexJournalItem(item).body - if ( - translated && - translated.kind !== 'turn' && - translated.kind !== 'approval' && - translated.kind !== 'question' - ) { - shared.assembler.apply({ type: 'item.close', item: item.id, body: translated, join }) - } - } - shared.assembler.apply({ - type: 'turn.end', - turn: 'turn-1', - at: turn.completedAt * 1000, - state: 'completed', - outcome: 'success' - }) - - expect(content(await shared.rows())).toBe(content(await codex.rows())) - }) - - it('lands the same rows across a restart in the middle of a streamed message', async () => { - const codex = await openProviderTimelineRig() - const translator = liveTranslator(codex) - const first: CodexThreadItem = { type: 'agentMessage', id: 'item-1', text: 'First' } - const second: CodexThreadItem = { type: 'agentMessage', id: 'item-2', text: 'Second' } - translator.handle(notify('turn/started', { turn: { id: 'turn-1' } })) - translator.handle(notify('item/completed', { item: first })) - translator.handle(notify('item/started', { item: { ...second, text: '' } })) - translator.handle(notify('item/agentMessage/delta', { itemId: 'item-2', delta: 'Sec' })) - translator.handle(notify('item/agentMessage/delta', { itemId: 'item-2', delta: 'ond' })) - translator.handle(notify('item/completed', { item: second })) - - const shared = await codexRig() - const join = { thread: THREAD, turn: 'turn-1' } - shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) - shared.assembler.apply({ type: 'item.close', item: 'item-1', body: body(first), join }) - shared.assembler.apply({ - type: 'text.delta', - item: { id: 'item-2' }, - channel: 'assistant', - text: 'Sec', - join - }) - await shared.rows() - // The host restarts while Codex is still streaming item-2. - const restarted = shared.restart({ generation: 'gen-2' }) - restarted.apply({ - type: 'text.delta', - item: { id: 'item-2' }, - channel: 'assistant', - text: 'ond', - join - }) - restarted.apply({ type: 'item.close', item: 'item-2', body: body(second), join }) - - const rows = await shared.rows() - expect(content(rows)).toBe(content(await codex.rows())) - // Codex message rows carry the item id their ordinal identity cannot spell. - expect( - rows.filter((row) => row.body.kind === 'message').map((row) => row.providerItemRef) - ).toEqual([expect.stringContaining('item-1'), expect.stringContaining('item-2')]) - }) - - it('lands the same rows, and no second copy, when a late repeat outlives the join cache', async () => { - const codex = await openProviderTimelineRig() - const translator = liveTranslator(codex) - const shared = await codexRig() - const old: CodexThreadItem = { type: 'agentMessage', id: 'old-1', text: 'Old' } - translator.handle(notify('turn/started', { turn: { id: 'turn-1' } })) - translator.handle(notify('item/completed', { item: old })) - translator.handle(notify('turn/completed', { turn: { id: 'turn-1', status: 'completed' } })) - translator.handle(notify('turn/started', { turn: { id: 'turn-2' } })) - shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) - shared.assembler.apply({ - type: 'item.close', - item: 'old-1', - body: body(old), - join: { thread: THREAD } - }) - shared.assembler.apply({ - type: 'turn.end', - turn: 'turn-1', - at: 1_000, - state: 'completed', - outcome: 'success' - }) - shared.assembler.apply({ type: 'turn.open', turn: 'turn-2', at: 1_000 }) - for (let index = 0; index < 1_025; index += 1) { - const item: CodexThreadItem = { type: 'agentMessage', id: `item-${index}`, text: 'small' } - translator.handle(notify('item/completed', { item })) - shared.assembler.apply({ - type: 'item.close', - item: item.id, - body: body(item), - join: { thread: THREAD } - }) - if (index % 32 === 31) { - await Promise.all([codex.rows(), shared.rows()]) - } - } - // Long after its join left memory, Codex repeats the old message with no turn of its own. - shared.assembler.apply({ - type: 'item.close', - item: 'old-1', - body: body(old), - join: { thread: THREAD } - }) - - expect(content(await shared.rows())).toBe(content(await codex.rows())) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts b/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts index 1dd1115ebd19..b09d387603ea 100644 --- a/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts +++ b/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts @@ -1,5 +1,6 @@ import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' import { isRunningAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { cancelledJournalPromptBody } from './journal-prompt-body-bounds' /** True while an item is still awaiting the row that settles it, so a sink can * treat that row as lifecycle-critical rather than sheddable under pressure. */ @@ -12,3 +13,15 @@ export function requiresTerminalSettlement(body: AgentJournalItemBody): boolean } return isRunningAgentJournalTurn(body) } + +/** The row that settles an item no one will finish: a running tool call fails, a pending prompt + * is cancelled. Null for an item that needs none. Turn rows are each writer's own to end. */ +export function terminalAgentJournalBody(body: AgentJournalItemBody): AgentJournalItemBody | null { + if (body.kind === 'tool-call') { + return body.state === 'running' ? { ...body, state: 'failed' } : null + } + if (body.kind === 'approval' || body.kind === 'question') { + return body.resolution.state === 'pending' ? cancelledJournalPromptBody(body) : null + } + return null +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts deleted file mode 100644 index 12804f3150b3..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts +++ /dev/null @@ -1,35 +0,0 @@ -// What the event appliers share with the assembler that owns them. - -import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import type { ProviderTimelineHold } from './provider-timeline-budget' -import type { ProviderTimelineContext } from './provider-timeline-context' -import type { ProviderTimelineDropRule } from './provider-timeline-decisions' -import type { ProviderTimelinePlan } from './provider-timeline-plan' -import type { ProviderTimelineState } from './provider-timeline-state' -import type { ProviderTimelineTextStreams } from './provider-timeline-text-streams' - -export type ProviderTimelineApplyResult = { - /** The sink's answer for the event's writes; refused means nothing changed. */ - admission: StructuredAgentSessionSinkAdmission - dropped?: ProviderTimelineDropRule -} - -/** An admitted transition that has not landed: what it changes in the forecast until it does. */ -export type ProviderTimelineInFlight = { - executed: boolean - commit: ((state: ProviderTimelineState) => void) | undefined -} - -export type ProviderTimelineApplyHost = { - context: ProviderTimelineContext - streams: ProviderTimelineTextStreams - /** The forecast now; it is replaced as transitions land. */ - forecast(): ProviderTimelineState - journal(): StructuredAgentSessionTransitionJournal | null - admits(hold: ProviderTimelineHold): boolean - submit( - plan: ProviderTimelinePlan, - entry: ProviderTimelineInFlight - ): StructuredAgentSessionSinkAdmission -} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts index 92238e697090..c49d266e3432 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts @@ -4,19 +4,15 @@ import { afterEach, describe, expect, it } from 'vitest' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' -import { createCodexProviderTimelineIdentityScheme } from '../../codex/codex-provider-timeline-identity' import { - assistantText, backgroundTask, backgroundTaskState, closeProviderTimelineRigs, - messageText, openProviderTimelineRig, openUnboundProviderTimelineAssembler, pendingApproval, providerItemId, providerTurnItemId, - SESSION, type ProviderTimelineRig } from './provider-timeline-assembler-test-support' @@ -93,43 +89,28 @@ describe('a request key reused in another turn', () => { }) }) - it('is a replay when it names the turn that is over', async () => { - const rig = await answeredInAnEarlierTurn() - expect( - rig.assembler.apply({ - type: 'request.open', - request: 'p1', - body: pendingApproval, - join: { turn: 't1' } - }).dropped - ).toBe('request-replayed') - expect(await rig.row(second)).toBeUndefined() - }) - - it('decides at execution: queued before bind, a replay of the old turn writes nothing', async () => { - const rig = await answeredInAnEarlierTurn() - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) - const join = { turn: 't1' } + it('opens a new row for a request id the previous process used, after a restart', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: '0', body: pendingApproval }) + await rig.rows() + // JSON-RPC ids restart with the provider process: the new child asks under `0` again. + const restarted = await rig.restart() + restarted.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) expect( - assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval, join }).dropped + restarted.apply({ type: 'request.open', request: '0', body: pendingApproval }).dropped ).toBeUndefined() - await bind() - expect(await rig.row(second)).toBeUndefined() - expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ - resolution: { state: 'resolved' } + // The sweep cancelled the old process's prompt; the new one is its own row, still pending. + expect((await rig.row(providerItemId('request', '0')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + expect(await rig.row(providerItemId('request', '0', { generation: 'gen-2' }))).toMatchObject({ + body: { resolution: { state: 'pending' } }, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t2') } }) }) - it('decides at execution: queued before bind, a new prompt in the live turn opens', async () => { - const rig = await answeredInAnEarlierTurn() - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) - const join = { turn: 't2' } - assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval, join }) - await bind() - expect((await rig.row(second))?.body).toMatchObject({ resolution: { state: 'pending' } }) - }) - - it('decides at execution: a turn another writer ended while it was queued takes no prompt', async () => { + it('asks nothing in a turn another writer ended while the open was queued', async () => { const rig = await answeredInAnEarlierTurn() const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) const join = { turn: 't2' } @@ -144,7 +125,9 @@ describe('a request key reused in another turn', () => { { fence: 1, turnScope: { kind: 'thread' } } ) await bind() - expect(await rig.row(second)).toBeUndefined() + expect( + await rig.row(providerItemId('request', 'p1', { generation: 'gen-unbound' })) + ).toBeUndefined() }) }) @@ -203,23 +186,24 @@ describe('a send that names its turn', () => { }) describe('a background task', () => { - it('outlives its turn after a restart, and settles on its own update', async () => { + it('outlives its turn, and settles on its own update', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) rig.assembler.apply({ type: 'item.open', item: 'bg', body: backgroundTask('bg', 'working') }) - await rig.rows() - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) expect(await backgroundTaskState(rig, 'bg')).toBe('working') expect( - restarted.apply({ type: 'item.close', item: 'bg', body: backgroundTask('bg', 'done') }) + rig.assembler.apply({ type: 'item.close', item: 'bg', body: backgroundTask('bg', 'done') }) .dropped ).toBeUndefined() expect(await backgroundTaskState(rig, 'bg')).toBe('done') // A straggler progress report never re-lights it. expect( - restarted.apply({ type: 'item.update', item: 'bg', body: backgroundTask('bg', 'working') }) - .dropped + rig.assembler.apply({ + type: 'item.update', + item: 'bg', + body: backgroundTask('bg', 'working') + }).dropped ).toBe('item-settled') expect(await backgroundTaskState(rig, 'bg')).toBe('done') }) @@ -237,29 +221,3 @@ describe('a background task', () => { expect(await backgroundTaskState(rig, 'ran')).toBe('done') }) }) - -describe('message places after a restart', () => { - it('continues past the highest place the journal holds, not the first gap', async () => { - const rig = await openProviderTimelineRig({ - scheme: createCodexProviderTimelineIdentityScheme({ - sessionId: SESSION, - primaryThreadId: () => 'root' - }), - ownThread: () => 'root' - }) - await rig.journal.appendItem( - { provider: 'codex', threadId: 'root', turnId: 't1', ordinal: 2 }, - assistantText('old'), - { fence: 1, turnScope: { kind: 'thread' }, providerItemRef: 'item:old' } - ) - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ - type: 'item.close', - item: 'new', - body: assistantText('new'), - join: { thread: 'root', turn: 't1' } - }) - const placed = (await rig.rows()).find((row) => messageText(row.body) === 'new') - expect(placed?.itemId).toBe('codex:root:t1:3') - }) -}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-held-rows.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-held-rows.test.ts new file mode 100644 index 000000000000..8d01dfbbb4d7 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-held-rows.test.ts @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + pendingApproval, + providerItemId, + providerTurnItemId, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const answered = { + ...pendingApproval, + resolution: { + state: 'resolved' as const, + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 1_500 + } +} + +describe('what the journal already holds stands', () => { + it('settles at the session end the work the journal holds open, from its rows', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + const identity = parseAgentJournalItemKey(providerItemId('item', 'done')) + if (!identity) { + throw new Error('item key did not parse') + } + // Another writer settled one row in the turn; the session end settles only what is still open. + await rig.journal.appendItem( + identity, + { ...runningTool('grep'), state: 'completed' }, + { fence: 1, turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } } + ) + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 2_000 } + }) + expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', completedAt: 2_000 }) + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) + expect((await rig.row(providerItemId('item', 'done')))?.body).toMatchObject({ + state: 'completed' + }) + }) + + it('never resurrects a settled tool from a running snapshot', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.close', + item: 'tool', + body: { ...runningTool('read'), state: 'completed' } + }) + expect( + rig.assembler.apply({ type: 'item.update', item: 'tool', body: runningTool('read') }).dropped + ).toBe('item-settled') + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 2_000 } + }) + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ + state: 'completed' + }) + }) +}) + +describe('requests', () => { + it('opens a request reused after it settled as a new one, and withdraws each on its own', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + await rig.rows() + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + + const prompts = (await rig.rows()).filter((row) => row.body.kind === 'approval') + expect(prompts.map((row) => row.itemId)).toEqual([ + providerItemId('request', 'p1'), + providerItemId('request', 'p1', { incarnation: 2 }) + ]) + expect(prompts.map((row) => row.body)).toEqual([ + expect.objectContaining({ resolution: expect.objectContaining({ state: 'cancelled' }) }), + expect.objectContaining({ resolution: expect.objectContaining({ state: 'cancelled' }) }) + ]) + }) + + it('opens a new request when the pending one under its key was answered', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) + if (!identity) { + throw new Error('request key did not parse') + } + await rig.journal.appendItem(identity, answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + expect( + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }).dropped + ).toBeUndefined() + // The answered prompt keeps its answer; withdrawing reaches only the new one. + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved' } + }) + expect( + (await rig.row(providerItemId('request', 'p1', { incarnation: 2 })))?.body + ).toMatchObject({ + resolution: { state: 'cancelled' } + }) + }) + + it('leaves an answered request answered when its withdrawal lands after the answer', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) + if (!identity) { + throw new Error('request key did not parse') + } + // Memory still holds it pending; only the journal knows a client answered. + await rig.journal.appendItem(identity, answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow' } + }) + }) + + it('never overwrites a request row already in the journal when its open runs', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) + if (!identity) { + throw new Error('request key did not parse') + } + await rig.journal.appendItem(identity, answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + // Another assembler of the same generation, blind to the journal, opens `p1` again. + const blind = rig.assemble({ sink: { ...rig.sink, journalItems: () => null } }) + blind.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow' } + }) + expect( + (await rig.row(providerItemId('request', 'p1', { incarnation: 2 })))?.body + ).toMatchObject({ resolution: { state: 'pending' } }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts deleted file mode 100644 index b63560978336..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts +++ /dev/null @@ -1,402 +0,0 @@ -// Memory is a cache: what the assembler forgot (a restart, an evicted entry, a write the journal -// rejected, an event the sink refused) never changes which row an event lands on or what it says. - -import { afterEach, describe, expect, it } from 'vitest' -import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { createCodexProviderTimelineIdentityScheme } from '../../codex/codex-provider-timeline-identity' -import { createDeferredStructuredAgentSessionEventSink } from '../agent-session-wire/structured-agent-session-event-sink' -import { testEventSinkLogging } from '../agent-session-wire/structured-agent-session-logger-test-support' -import { createProviderTimelineAssembler } from './provider-timeline-assembler' -import { - AGENT, - assistantText, - backgroundTask, - closeProviderTimelineRigs, - messageText, - NAMESPACE, - openProviderTimelineRig, - pendingApproval, - providerItemId, - providerTurnId, - providerTurnItemId, - refusingSink, - runningTool, - SESSION -} from './provider-timeline-assembler-test-support' -import { providerTimelineSink } from './provider-timeline-plan' - -afterEach(closeProviderTimelineRigs) - -const codexRig = () => - openProviderTimelineRig({ - scheme: createCodexProviderTimelineIdentityScheme({ - sessionId: SESSION, - primaryThreadId: () => 'root' - }), - ownThread: () => 'root' - }) - -const messages = async (rig: Awaited>) => - (await rig.rows()).filter((row) => row.body.kind === 'message') - -describe('a forgotten join finds its original row', () => { - it('finishes an in-flight message after a restart without overwriting an earlier one', async () => { - const rig = await codexRig() - const join = { thread: 'root', turn: 't1' } - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'item.close', item: 'm0', body: assistantText('First'), join }) - rig.assembler.apply({ type: 'item.open', item: 'm1', body: assistantText('Partial'), join }) - await rig.rows() - - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ type: 'item.close', item: 'm1', body: assistantText('Second'), join }) - restarted.apply({ type: 'item.close', item: 'm2', body: assistantText('Third'), join }) - - expect((await messages(rig)).map((row) => [row.itemId, messageText(row.body)])).toEqual([ - ['codex:root:t1:0', 'First'], - ['codex:root:t1:1', 'Second'], - ['codex:root:t1:2', 'Third'] - ]) - }) - - it('resumes an in-flight streamed message after a restart from the text it already holds', async () => { - const rig = await codexRig() - const join = { thread: 'root', turn: 't1' } - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ - type: 'text.delta', - item: { id: 'm0' }, - channel: 'assistant', - text: 'Hel', - join - }) - await rig.rows() - - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ - type: 'text.delta', - item: { id: 'm0' }, - channel: 'assistant', - text: 'lo', - join - }) - restarted.apply({ type: 'text.close', item: { id: 'm0' }, join }) - - expect((await messages(rig)).map((row) => [row.itemId, messageText(row.body)])).toEqual([ - ['codex:root:t1:0', 'Hello'] - ]) - }) - - it('reaches a message in its own turn after its join was evicted', async () => { - const rig = await codexRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ - type: 'item.close', - item: 'old-message', - body: assistantText('Old'), - join: { thread: 'root' } - }) - rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) - rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) - for (let index = 0; index < 1_025; index += 1) { - rig.assembler.apply({ - type: 'item.close', - item: `m-${index}`, - body: assistantText('small'), - join: { thread: 'root' } - }) - if (index % 32 === 31) { - await rig.rows() - } - } - rig.assembler.apply({ - type: 'item.close', - item: 'old-message', - body: assistantText('Old'), - join: { thread: 'root' } - }) - - const old = (await rig.rows()).filter((row) => messageText(row.body) === 'Old') - expect(old.map((row) => row.itemId)).toEqual(['codex:root:t1:0']) - }) - - it('keeps a late background task update in the turn it opened in after its join was evicted', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ - type: 'item.open', - item: 'background-tool', - body: backgroundTask('background-tool', 'working') - }) - rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) - rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) - for (let index = 0; index < 1_025; index += 1) { - rig.assembler.apply({ type: 'item.close', item: `m-${index}`, body: assistantText('small') }) - if (index % 32 === 31) { - await rig.rows() - } - } - rig.assembler.apply({ - type: 'item.close', - item: 'background-tool', - body: backgroundTask('background-tool', 'done') - }) - - expect((await rig.row(providerItemId('item', 'background-tool')))?.turnScope).toEqual({ - kind: 'turn', - turnItemId: providerTurnItemId('t1') - }) - }) - - it('withdraws the request incarnation a previous run opened', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - await rig.rows() - - rig.restart({ generation: 'gen-2' }).apply({ type: 'request.withdrawn', request: 'p1' }) - - expect( - (await rig.row(providerItemId('request', 'p1', { incarnation: 2 })))?.body - ).toMatchObject({ resolution: { state: 'cancelled' } }) - }) -}) - -describe('streamed text has the same lifecycle as the item it streams into', () => { - it('keeps interleaved same-id streams on separate threads apart', async () => { - const rig = await codexRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ - type: 'text.delta', - item: { id: 'm1' }, - channel: 'assistant', - text: 'Root', - join: { thread: 'root', turn: 't1' } - }) - rig.assembler.apply({ - type: 'text.delta', - item: { id: 'm1' }, - channel: 'assistant', - text: 'Child', - join: { thread: 'child', turn: 'child-turn' } - }) - rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) - - expect((await messages(rig)).map((row) => [row.itemId, messageText(row.body)])).toEqual([ - ['codex:root:t1:0', 'Root'], - ['codex:child:child-turn:0', 'Child'] - ]) - }) - - it('never lets replayed text replace a message its settled turn completed', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'item.close', item: 'm1', body: assistantText('Full final text') }) - rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) - await rig.rows() - - const restarted = rig.restart({ generation: 'gen-2' }) - expect( - restarted.apply({ - type: 'text.delta', - item: { id: 'm1' }, - channel: 'assistant', - text: 'Full', - join: { turn: 't1' } - }).dropped - ).toBe('item-settled') - restarted.flush() - - expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Full final text') - }) - - it('settles a message on text.close, so a late delta cannot erase what it said', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ - type: 'text.delta', - item: { id: 'm1' }, - channel: 'assistant', - text: 'Prefix' - }) - rig.assembler.apply({ type: 'text.close', item: { id: 'm1' } }) - expect( - rig.assembler.apply({ - type: 'text.delta', - item: { id: 'm1' }, - channel: 'assistant', - text: 'Suffix' - }).dropped - ).toBe('item-settled') - rig.assembler.flush() - - expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Prefix') - }) -}) - -describe('a write the journal rejects changes nothing the assembler knows', () => { - it('keeps a running tool open when a replayed supersede it planned before bind lands as nothing', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) - rig.assembler.apply({ type: 'turn.end', turn: 'old', at: 2_000, state: 'completed' }) - rig.assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) - await rig.rows() - - const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - const sink = providerTimelineSink(deferred.sink) - if (!sink) { - throw new Error('the deferred sink offers transitions') - } - const assembler = createProviderTimelineAssembler({ - sink, - sessionId: SESSION, - agent: AGENT, - generation: 'gen-2', - namespace: NAMESPACE - }) - // Planned before bind: nothing tells the planner the journal already holds both turns. - assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) - assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) - assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) - deferred.bind({ journal: rig.journal, fence: 1, publish: () => {} }) - await deferred.drained() - expect(await rig.turn('live')).toMatchObject({ state: 'running' }) - expect(assembler.openTurnId).toBe(providerTurnId('live')) - - const closed = assembler.apply({ - type: 'item.close', - item: 'tool', - body: { ...runningTool('read'), state: 'completed' } - }) - expect(closed.dropped).toBeUndefined() - await deferred.drained() - expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ - state: 'completed' - }) - deferred.close() - assembler.dispose() - }) - - it('takes no message ordinal for an event the sink refused', async () => { - const rig = await codexRig() - let refusing = false - const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) - const join = { thread: 'root', turn: 't1' } - assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - refusing = true - expect( - assembler.apply({ type: 'item.close', item: 'refused', body: assistantText('No'), join }) - .admission - ).toEqual({ accepted: false, reason: 'backpressure' }) - refusing = false - assembler.apply({ type: 'item.close', item: 'accepted', body: assistantText('Yes'), join }) - - expect((await messages(rig)).map((row) => row.itemId)).toEqual(['codex:root:t1:0']) - }) -}) - -describe('reset settles the old session whatever the assembler remembers', () => { - it('writes text a reset interrupts, though nothing else was open', async () => { - const rig = await openProviderTimelineRig({ schedule: () => () => {} }) - rig.assembler.apply({ - type: 'text.delta', - item: { stream: 'reply' }, - channel: 'assistant', - text: 'Before reset' - }) - rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) - - expect((await messages(rig)).map((row) => messageText(row.body))).toEqual(['Before reset']) - }) - - it('settles the journal work of a previous run as lost', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) - await rig.rows() - - rig.restart({ generation: 'gen-2' }).apply({ - type: 'session.reset', - namespace: 'provider-session-2' - }) - - expect(await rig.turn('t1')).toMatchObject({ state: 'unverifiable' }) - expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ - state: 'failed' - }) - }) -}) - -describe('the budget counts the work actually open', () => { - it('frees an answered request incarnation without waiting for its turn to end', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - for (let incarnation = 1; incarnation <= 129; incarnation += 1) { - expect( - rig.assembler.apply({ type: 'request.open', request: 'approval', body: pendingApproval }) - .admission - ).toEqual({ accepted: true }) - await rig.rows() - const identity = parseAgentJournalItemKey( - providerItemId('request', 'approval', { incarnation }) - ) - if (!identity) { - throw new Error('the request has a journal key') - } - // A client answers it, through the journal. - await rig.journal.appendItem( - identity, - { - ...pendingApproval, - resolution: { - state: 'resolved', - selectedOptionId: 'allow', - resolvedBy: 'phone', - resolvedAt: 1_100 - } - }, - { fence: 1, turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } } - ) - } - }) - - it('keeps a streamed message counted while its stream is open, whatever its snapshots say', async () => { - const rig = await openProviderTimelineRig({ schedule: () => () => {} }) - for (let index = 0; index < 128; index += 1) { - rig.assembler.apply({ - type: 'text.delta', - item: { id: `stream-${index}` }, - channel: 'assistant', - text: 'x' - }) - rig.assembler.apply({ - type: 'item.update', - item: `stream-${index}`, - body: assistantText('x') - }) - if (index % 32 === 31) { - await rig.rows() - } - } - expect( - rig.assembler.apply({ - type: 'text.delta', - item: { id: 'overflow' }, - channel: 'assistant', - text: 'x' - }).admission - ).toEqual({ accepted: false, reason: 'failed' }) - }) - - it('refuses a provider key larger than the whole budget', () => - openProviderTimelineRig({ schedule: () => () => {} }).then((rig) => { - expect( - rig.assembler.apply({ - type: 'text.delta', - item: { id: 'x'.repeat(1024 * 1024 + 1) }, - channel: 'assistant', - text: 'tiny' - }).admission - ).toEqual({ accepted: false, reason: 'failed' }) - })) -}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts index 4551013a3c64..2580926b30a2 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts @@ -18,7 +18,7 @@ describe('a refused event changes nothing and its retry lands it once', () => { it('settles the tool a refused turn end owed when the end is re-applied', async () => { const rig = await openProviderTimelineRig() let refusing = false - const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) + const assembler = rig.assemble({ sink: refusingSink(rig.sink, () => refusing) }) assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) refusing = true @@ -36,7 +36,7 @@ describe('a refused event changes nothing and its retry lands it once', () => { it('keeps observed text when the close that would write it is refused', async () => { const rig = await openProviderTimelineRig() let refusing = false - const assembler = rig.restart({ + const assembler = rig.assemble({ sink: refusingSink(rig.sink, () => refusing), schedule: () => () => {} }) @@ -59,7 +59,7 @@ describe('a refused event changes nothing and its retry lands it once', () => { const runs: (() => void)[] = [] const rig = await openProviderTimelineRig() let refusal: 'backpressure' | 'failed' | null = null - const assembler = rig.restart({ + const assembler = rig.assemble({ sink: { ...rig.sink, tryAppendTransition: (transition) => @@ -90,7 +90,7 @@ describe('a refused event changes nothing and its retry lands it once', () => { it('does not latch a refused session end, so its retry still settles the session', async () => { const rig = await openProviderTimelineRig() let refusing = false - const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) + const assembler = rig.assemble({ sink: refusingSink(rig.sink, () => refusing) }) assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) refusing = true @@ -115,7 +115,7 @@ describe('a refused event changes nothing and its retry lands it once', () => { it('leaves the open turn in place when a superseding open is refused', async () => { const rig = await openProviderTimelineRig() let refusing = false - const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) + const assembler = rig.assemble({ sink: refusingSink(rig.sink, () => refusing) }) assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) const first = assembler.openTurnId refusing = true @@ -128,21 +128,6 @@ describe('a refused event changes nothing and its retry lands it once', () => { expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', outcome: 'superseded' }) expect(await rig.turn('t2')).toMatchObject({ state: 'running' }) }) - - it('keeps the old session open when the reset that would settle it is refused', async () => { - const rig = await openProviderTimelineRig() - let refusing = false - const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) - assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - refusing = true - const reset = { type: 'session.reset', namespace: 'provider-session-2' } as const - expect(assembler.apply(reset).admission).toEqual(BACKPRESSURE) - expect(assembler.openTurnId).not.toBeNull() - refusing = false - assembler.apply(reset) - expect(assembler.openTurnId).toBeNull() - expect(await rig.turn('t1')).toMatchObject({ state: 'unverifiable' }) - }) }) describe('what the assembler holds open is bounded', () => { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts deleted file mode 100644 index bb0eee27f429..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { - closeProviderTimelineRigs, - openProviderTimelineRig, - pendingApproval, - providerItemId, - providerTurnItemId, - runningTool -} from './provider-timeline-assembler-test-support' - -afterEach(closeProviderTimelineRigs) - -const answered = { - ...pendingApproval, - resolution: { - state: 'resolved' as const, - selectedOptionId: 'allow', - resolvedBy: 'phone', - resolvedAt: 1_500 - } -} - -describe('a replay never changes what the journal already holds', () => { - it('keeps an answered prompt answered when a restarted host replays its open and its turn', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - const itemId = providerItemId('request', 'p1') - const identity = parseAgentJournalItemKey(itemId) - if (!identity) { - throw new Error('request key did not parse') - } - // The answer path's compare-and-set lands first, with no word to the assembler. - await rig.journal.appendItem(identity, answered, { - fence: 1, - turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } - }) - rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) - expect((await rig.row(itemId))?.body).toMatchObject({ resolution: { state: 'resolved' } }) - - const restarted = rig.restart({ generation: 'gen-2' }) - expect(restarted.apply({ type: 'turn.open', turn: 't1', at: 3_000 }).dropped).toBe( - 'turn-replayed' - ) - expect( - restarted.apply({ type: 'request.open', request: 'p1', body: pendingApproval }).dropped - ).toBe('request-replayed') - restarted.apply({ type: 'turn.end', at: 4_000, state: 'completed' }) - expect((await rig.row(itemId))?.body).toMatchObject({ - resolution: { state: 'resolved', selectedOptionId: 'allow' } - }) - }) - - it('does not let a replayed open of a settled turn supersede the live one', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) - rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) - await rig.rows() - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) - restarted.apply({ type: 'item.open', item: 'live-tool', body: runningTool('read') }) - const live = restarted.openTurnId - - expect(restarted.apply({ type: 'turn.open', turn: 'old', at: 1_000 }).dropped).toBe( - 'turn-replayed' - ) - expect(restarted.openTurnId).toBe(live) - expect(await rig.turn('live')).toMatchObject({ state: 'running' }) - expect((await rig.row(providerItemId('item', 'live-tool')))?.body).toMatchObject({ - state: 'running' - }) - }) - - it('settles a turn the journal holds running even when the assembler that opened it is gone', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) - await rig.rows() - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) - expect(await rig.turn('t1')).toMatchObject({ state: 'completed', completedAt: 2_000 }) - expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) - }) - - it('takes its open turn back from the journal when a planned open found the row already there', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 'live', at: 1_000 }) - await rig.rows() - const live = rig.assembler.openTurnId - // The plan reads no journal yet (as before it binds); the write still asks it when it runs. - let lagging = true - const stale = rig.restart({ - sink: { ...rig.sink, journalItems: () => (lagging ? null : rig.sink.journalItems()) }, - generation: 'gen-2' - }) - expect(stale.apply({ type: 'turn.open', turn: 'live', at: 1_000 }).dropped).toBeUndefined() - lagging = false - await rig.rows() - await Promise.resolve() - expect(stale.openTurnId).toBe(live) - expect(await rig.turn('live')).toMatchObject({ state: 'running', startedAt: 1_000 }) - }) - - it('never resurrects a settled tool from a running snapshot', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ - type: 'item.close', - item: 'tool', - body: { ...runningTool('read'), state: 'completed' } - }) - expect( - rig.assembler.apply({ type: 'item.update', item: 'tool', body: runningTool('read') }).dropped - ).toBe('item-settled') - rig.assembler.apply({ - type: 'session.ended', - verdict: { state: 'interrupted', completedAt: 2_000 } - }) - expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ - state: 'completed' - }) - }) - - it('does not reopen a settled tool a restarted host replays', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) - rig.assembler.apply({ - type: 'item.close', - item: 'tool', - body: { ...runningTool('read'), state: 'completed' } - }) - await rig.rows() - const restarted = rig.restart({ generation: 'gen-2' }) - expect( - restarted.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }).dropped - ).toBe('item-replayed') - expect( - restarted.apply({ type: 'item.update', item: 'tool', body: runningTool('read') }).dropped - ).toBe('item-settled') - expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ - state: 'completed' - }) - }) -}) - -describe('requests', () => { - it('opens a request reused after it settled as a new one, and withdraws each on its own', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) - await rig.rows() - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) - - const prompts = (await rig.rows()).filter((row) => row.body.kind === 'approval') - expect(prompts.map((row) => row.itemId)).toEqual([ - providerItemId('request', 'p1'), - providerItemId('request', 'p1', { incarnation: 2 }) - ]) - expect(prompts.map((row) => row.body)).toEqual([ - expect.objectContaining({ resolution: expect.objectContaining({ state: 'cancelled' }) }), - expect.objectContaining({ resolution: expect.objectContaining({ state: 'cancelled' }) }) - ]) - }) - - it('opens a new request when the pending one under its key was answered', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) - if (!identity) { - throw new Error('request key did not parse') - } - await rig.journal.appendItem(identity, answered, { - fence: 1, - turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } - }) - expect( - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }).dropped - ).toBeUndefined() - // The answered prompt keeps its answer; withdrawing reaches only the new one. - rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) - expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ - resolution: { state: 'resolved' } - }) - expect( - (await rig.row(providerItemId('request', 'p1', { incarnation: 2 })))?.body - ).toMatchObject({ - resolution: { state: 'cancelled' } - }) - }) - - it('leaves an answered request answered when its withdrawal lands after the answer', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) - if (!identity) { - throw new Error('request key did not parse') - } - // Memory still holds it pending; only the journal knows a client answered. - await rig.journal.appendItem(identity, answered, { - fence: 1, - turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } - }) - rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) - expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ - resolution: { state: 'resolved', selectedOptionId: 'allow' } - }) - }) - - it('never overwrites a request row it could not see when it planned the open', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) - if (!identity) { - throw new Error('request key did not parse') - } - await rig.journal.appendItem(identity, answered, { - fence: 1, - turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } - }) - const blind = rig.restart({ - sink: { ...rig.sink, journalItems: () => null }, - generation: 'gen-2' - }) - blind.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ - resolution: { state: 'resolved', selectedOptionId: 'allow' } - }) - }) -}) - -describe('a new provider session is a new id space', () => { - it('gives a reused provider turn id its own row after a reset', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: '1', at: 1_000 }) - rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) - rig.assembler.apply({ - type: 'session.ended', - verdict: { state: 'interrupted', completedAt: 2_000 } - }) - rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) - rig.assembler.apply({ type: 'turn.open', turn: '1', at: 3_000 }) - - expect((await rig.turns()).map((turn) => turn.state)).toEqual(['completed', 'running']) - expect(await rig.turn('1', 'provider-session-2')).toMatchObject({ startedAt: 3_000 }) - }) - - it('does not overwrite an earlier session’s item when the provider reuses its id', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ - type: 'item.close', - item: 'tool-1', - body: { ...runningTool('first'), state: 'completed' } - }) - rig.assembler.apply({ - type: 'session.ended', - verdict: { state: 'interrupted', completedAt: 2_000 } - }) - rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) - rig.assembler.apply({ - type: 'item.close', - item: 'tool-1', - body: { ...runningTool('second'), state: 'completed' } - }) - const tools = (await rig.rows()).filter((row) => row.body.kind === 'tool-call') - expect(tools.map((row) => row.body)).toEqual([ - expect.objectContaining({ name: 'first' }), - expect.objectContaining({ name: 'second' }) - ]) - }) - - it('settles the open work of a session reset before anything ended it, as lost', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) - rig.assembler.apply({ - type: 'text.delta', - item: { stream: 'reply' }, - channel: 'assistant', - text: 'Half' - }) - rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) - rig.assembler.apply({ type: 'session.ended', verdict: { state: 'unverifiable' } }) - - expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) - expect(await rig.turn('t1')).toMatchObject({ state: 'unverifiable' }) - expect((await rig.rows()).filter((row) => row.body.kind === 'message')).toHaveLength(1) - }) -}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts new file mode 100644 index 000000000000..5b4033e3ff6d --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts @@ -0,0 +1,153 @@ +// A new provider child is a new assembler. Its events can be admitted before its sink binds, and +// the dead-generation sweep for the previous child lands before they are written, so every write +// decides from the journal the sweep left. + +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { settleStaleStructuredAgentSessionState } from '../agent-session-wire/structured-agent-session-dead-generation-settlement' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + openUnboundProviderTimelineAssembler, + pendingApproval, + providerItemId, + providerTurnItemId, + runningTool, + SESSION, + type ProviderTimelineRig +} from './provider-timeline-assembler-test-support' +import type { ProviderTimelineEvent } from './provider-timeline-event' + +afterEach(closeProviderTimelineRigs) + +const userText = (text: string): AgentJournalMessageItem => ({ + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text }] +}) + +/** A provider's saved history of one prompt, as an adapter adopting the session replays it. */ +const history: ProviderTimelineEvent[] = [ + { type: 'input.history', item: 'u1', body: userText('Fix it'), join: { turn: 'p1' } }, + { type: 'turn.open', turn: 'p1', at: 1_000 }, + { + type: 'text.delta', + item: { id: 'a1' }, + channel: 'assistant', + text: 'Looking', + join: { turn: 'p1' } + }, + { type: 'text.close', item: { id: 'a1' }, join: { turn: 'p1' } }, + { type: 'item.open', item: 'call-1', body: runningTool('read'), join: { turn: 'p1' } }, + { + type: 'item.close', + item: 'call-1', + body: { ...runningTool('read'), state: 'completed' }, + join: { turn: 'p1' } + }, + { type: 'turn.end', turn: 'p1', at: 2_000, state: 'completed', outcome: 'success' } +] + +async function revisions(rig: ProviderTimelineRig): Promise<[string, number][]> { + return (await rig.rows()).map((row) => [row.itemId, row.revision]) +} + +describe('an adopted session', () => { + it('lands the provider history as rows keyed by its own ids, opened by its saved message', async () => { + const rig = await openProviderTimelineRig() + history.forEach((event) => rig.assembler.apply(event)) + rig.assembler.flush() + + const user = await rig.row(providerItemId('item', 'u1')) + expect(user).toMatchObject({ + body: userText('Fix it'), + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('p1') } + }) + expect(await rig.turn('p1')).toMatchObject({ + state: 'completed', + outcome: 'success', + userItemId: user?.itemId + }) + expect(messageText((await rig.row(providerItemId('item', 'a1')))?.body)).toBe('Looking') + expect((await rig.row(providerItemId('item', 'call-1')))?.body).toMatchObject({ + state: 'completed' + }) + + // Adoption run again by the next child (its first try was interrupted): nothing new lands. + const before = await revisions(rig) + const again = await rig.restart() + history.forEach((event) => again.apply(event)) + again.flush() + expect(await revisions(rig)).toEqual(before) + }) + + it('re-runs an adoption a crash cut midway without a second copy or a regressed row', async () => { + const rig = await openProviderTimelineRig() + history.slice(0, 5).forEach((event) => rig.assembler.apply(event)) + rig.assembler.flush() + await rig.rows() + // The child died with the turn and its tool running; the next acquisition sweeps them. + await rig.restart() + const swept = await rig.turn('p1') + expect(swept?.state).not.toBe('running') + const before = (await rig.rows()).length + + // The new child replays the whole history before its sink binds: only the writes can see it. + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal, { + generation: 'gen-3' + }) + history.forEach((event) => expect(assembler.apply(event).dropped).toBeUndefined()) + assembler.flush() + await bind() + + expect(await rig.rows()).toHaveLength(before) + expect(await rig.turn('p1')).toEqual(swept) + expect((await rig.row(providerItemId('item', 'call-1')))?.body).toMatchObject({ + state: 'failed' + }) + expect(messageText((await rig.row(providerItemId('item', 'a1')))?.body)).toBe('Looking') + }) +}) + +describe('a resumed session', () => { + it('decides events admitted before bind against the journal the sweep left', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) + rig.assembler.apply({ type: 'request.open', request: '0', body: pendingApproval }) + await rig.rows() + + // The next child's events are admitted while its sink is unbound. + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal, { + generation: 'gen-2' + }) + const late = { type: 'item.update', item: 'call-a', body: runningTool('read') } as const + assembler.apply({ ...late, join: { turn: 'old' } }) + assembler.apply({ type: 'turn.open', turn: 'new', at: 3_000 }) + assembler.apply({ type: 'request.open', request: '0', body: pendingApproval }) + // The sweep for the dead child lands before the drain. + await settleStaleStructuredAgentSessionState({ + journal: rig.journal, + sessionId: SESSION, + fence: 1, + acquisitionGeneration: 'gen-2', + deathEvidence: null + }) + await bind() + + expect((await rig.turn('old'))?.state).not.toBe('running') + // The swept tool is not relit by a running report that was admitted before the sweep. + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'failed' + }) + expect((await rig.row(providerItemId('request', '0')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + expect(await rig.turn('new')).toMatchObject({ state: 'running', startedAt: 3_000 }) + expect(await rig.row(providerItemId('request', '0', { generation: 'gen-2' }))).toMatchObject({ + body: { resolution: { state: 'pending' } }, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('new') } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-rows.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-rows.test.ts new file mode 100644 index 000000000000..8e4de9e95ae9 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-rows.test.ts @@ -0,0 +1,207 @@ +// Which row an event lands on is spelled from its keys, and an event the sink refused took +// nothing: no key, no row, no room in the budget. + +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + assistantText, + backgroundTask, + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + pendingApproval, + providerItemId, + providerTurnItemId, + refusingSink +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const messages = async (rig: Awaited>) => + (await rig.rows()).filter((row) => row.body.kind === 'message') + +describe('a refused event takes nothing', () => { + it('allocates no key and no request row, and its retry lands once', async () => { + const rig = await openProviderTimelineRig() + let refusing = false + const assembler = rig.assemble({ sink: refusingSink(rig.sink, () => refusing) }) + const refused = { accepted: false, reason: 'backpressure' } + const turnOpen = { type: 'turn.open', at: 1_000 } as const + const frame = { type: 'provider.frame', frameKind: 'mystery', payload: { a: 1 } } as const + const request = { type: 'request.open', request: 'p1', body: pendingApproval } as const + refusing = true + expect(assembler.apply(turnOpen).admission).toEqual(refused) + expect(assembler.openTurnId).toBeNull() + refusing = false + expect(assembler.apply(turnOpen).admission).toEqual({ accepted: true }) + // The minted turn takes the first serial, as if the refusal never happened. + expect(assembler.openTurnId).toBe('m:gen-1%3At1') + refusing = true + expect(assembler.apply(frame).admission).toEqual(refused) + expect(assembler.apply(request).admission).toEqual(refused) + refusing = false + expect(assembler.apply(frame).admission).toEqual({ accepted: true }) + expect(assembler.apply(request).admission).toEqual({ accepted: true }) + assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Hi' + }) + assembler.flush() + + const rows = (await rig.rows()).filter((row) => row.body.kind !== 'turn') + expect(rows.map((row) => row.itemId)).toEqual([ + expect.stringContaining('frame%3Am%3Agen-1%253Af2'), + providerItemId('request', 'p1'), + expect.stringContaining('item%3Am%3Agen-1%253As3') + ]) + expect(messageText(rows[2]?.body)).toBe('Hi') + }) +}) + +describe('a row keeps the turn it opened in', () => { + it('takes a background task update in the turn it opened in, after that turn ended', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.open', + item: 'background-tool', + body: backgroundTask('background-tool', 'working') + }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + // No turn is open: the update still joins the row's own turn, not the conversation. + rig.assembler.apply({ + type: 'item.update', + item: 'background-tool', + body: backgroundTask('background-tool', 'done') + }) + expect(await rig.row(providerItemId('item', 'background-tool'))).toMatchObject({ + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') }, + body: backgroundTask('background-tool', 'done') + }) + }) +}) + +describe('streamed text has the same lifecycle as the item it streams into', () => { + it('keeps interleaved same-id streams on separate threads apart', async () => { + const rig = await openProviderTimelineRig({ ownThread: () => 'root' }) + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Root', + join: { thread: 'root', turn: 't1' } + }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Child', + join: { thread: 'child', turn: 'child-turn' } + }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + + expect((await messages(rig)).map((row) => [row.itemId, messageText(row.body)])).toEqual([ + [providerItemId('item', 'm1', { thread: 'root' }), 'Root'], + [providerItemId('item', 'm1', { thread: 'child' }), 'Child'] + ]) + }) + + it('settles a message on text.close, so a late delta cannot erase what it said', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Prefix' + }) + rig.assembler.apply({ type: 'text.close', item: { id: 'm1' } }) + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Suffix' + }).dropped + ).toBe('item-settled') + rig.assembler.flush() + + expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Prefix') + }) +}) + +describe('the budget counts the work actually open', () => { + it('frees an answered request incarnation without waiting for its turn to end', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + for (let incarnation = 1; incarnation <= 129; incarnation += 1) { + expect( + rig.assembler.apply({ type: 'request.open', request: 'approval', body: pendingApproval }) + .admission + ).toEqual({ accepted: true }) + await rig.rows() + const identity = parseAgentJournalItemKey( + providerItemId('request', 'approval', { incarnation }) + ) + if (!identity) { + throw new Error('the request has a journal key') + } + // A client answers it, through the journal. + await rig.journal.appendItem( + identity, + { + ...pendingApproval, + resolution: { + state: 'resolved', + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 1_100 + } + }, + { fence: 1, turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } } + ) + } + }) + + it('keeps a streamed message counted while its stream is open, whatever its snapshots say', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + for (let index = 0; index < 128; index += 1) { + rig.assembler.apply({ + type: 'text.delta', + item: { id: `stream-${index}` }, + channel: 'assistant', + text: 'x' + }) + rig.assembler.apply({ + type: 'item.update', + item: `stream-${index}`, + body: assistantText('x') + }) + if (index % 32 === 31) { + await rig.rows() + } + } + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'overflow' }, + channel: 'assistant', + text: 'x' + }).admission + ).toEqual({ accepted: false, reason: 'failed' }) + }) + + it('refuses a provider key larger than the whole budget', () => + openProviderTimelineRig({ schedule: () => () => {} }).then((rig) => { + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'x'.repeat(1024 * 1024 + 1) }, + channel: 'assistant', + text: 'tiny' + }).admission + ).toEqual({ accepted: false, reason: 'failed' }) + })) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts index 3bc3aeb3caa5..fff993850eb1 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts @@ -67,7 +67,7 @@ describe('provider timeline session end', () => { expect(turn).not.toHaveProperty('outcome') }) - it('drops everything after the session ended until a reset starts a fresh namespace', async () => { + it('drops everything after the session ended', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', at: 1_000 }) const first = rig.assembler.openTurnId @@ -81,16 +81,9 @@ describe('provider timeline session end', () => { rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }).dropped ).toBe('session-ended') expect(rig.assembler.openTurnId).toBeNull() - - rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) - rig.assembler.apply({ type: 'turn.open', at: 3_000 }) - const second = rig.assembler.openTurnId - expect(second).not.toBeNull() - expect(second).not.toBe(first) const turns = (await rig.rows()).filter((row) => row.body.kind === 'turn') expect(turns.map((row) => row.body)).toEqual([ - expect.objectContaining({ turnId: first, state: 'interrupted' }), - expect.objectContaining({ turnId: second, state: 'running' }) + expect.objectContaining({ turnId: first, state: 'interrupted' }) ]) }) }) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts index d2094d9a4399..95b28378dfb3 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts @@ -7,49 +7,32 @@ import { openProviderTimelineRig, openUnboundProviderTimelineAssembler, providerItemId, - providerTurnItemId, - type ProviderTimelineRig + providerTurnItemId } from './provider-timeline-assembler-test-support' afterEach(closeProviderTimelineRigs) -/** A journal that already holds a settled `old` turn and a running `live` one. */ -async function oldAndLiveTurns(): Promise { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) - rig.assembler.apply({ type: 'turn.end', turn: 'old', at: 2_000, state: 'completed' }) - rig.assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) - await rig.rows() - return rig -} - -async function messages(rig: ProviderTimelineRig): Promise<(string | undefined)[]> { - return (await rig.rows()) - .filter((row) => row.body.kind === 'message') - .map((row) => messageText(row.body)) -} - -describe('a stream follows the row it resolved to, not the one planning expected', () => { - it('stops at the end of the turn its row landed in, though planning expected another', async () => { - const rig = await oldAndLiveTurns() +describe("a stream writes only while its row's turn runs", () => { + it('stops at the end of its turn, though its next delta was admitted before the end was written', async () => { + const rig = await openProviderTimelineRig() const { assembler, bind, drained } = openUnboundProviderTimelineAssembler(rig.journal) - // Planned before bind, so planning expects `old` to be open; it is a replay of a settled turn. - assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'Hel' }) - await bind() assembler.flush() + assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + // Admitted before bind: only its write can see that the turn is over. + expect( + assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'lo' }) + .dropped + ).toBeUndefined() + assembler.flush() + await bind() await drained() + expect(await rig.turn('t1')).toMatchObject({ state: 'completed' }) expect((await rig.row(providerItemId('item', 'm')))?.turnScope).toEqual({ kind: 'turn', - turnItemId: providerTurnItemId('live') + turnItemId: providerTurnItemId('t1') }) - - assembler.apply({ type: 'turn.end', turn: 'live', at: 4_000, state: 'completed' }) - await drained() - expect(await rig.turn('live')).toMatchObject({ state: 'completed' }) - assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'lo' }) - assembler.flush() - await drained() expect(messageText((await rig.row(providerItemId('item', 'm')))?.body)).toBe('Hel') }) @@ -100,66 +83,6 @@ describe('a stream follows the row it resolved to, not the one planning expected }) }) -describe('a message boundary is the journal decision', () => { - it('keeps an anonymous message whole across a queued open the journal held as a replay', async () => { - const rig = await oldAndLiveTurns() - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) - assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) - assembler.apply({ - type: 'text.delta', - item: { stream: 'reply' }, - channel: 'assistant', - text: 'Hel' - }) - assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) - assembler.apply({ - type: 'text.delta', - item: { stream: 'reply' }, - channel: 'assistant', - text: 'lo' - }) - assembler.apply({ type: 'text.close', item: { stream: 'reply' } }) - await bind() - expect(await messages(rig)).toEqual(['Hello']) - }) - - it('still splits an anonymous message at a turn the journal really opened', async () => { - const rig = await oldAndLiveTurns() - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) - assembler.apply({ - type: 'text.delta', - item: { stream: 'reply' }, - channel: 'assistant', - text: 'One' - }) - assembler.apply({ type: 'turn.open', turn: 'next', at: 4_000 }) - assembler.apply({ - type: 'text.delta', - item: { stream: 'reply' }, - channel: 'assistant', - text: 'Two' - }) - assembler.apply({ type: 'text.close', item: { stream: 'reply' } }) - await bind() - expect(await messages(rig)).toEqual(['One', 'Two']) - }) - - it('resumes a named message after a queued open the journal held as a replay', async () => { - const rig = await oldAndLiveTurns() - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) - assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'Hel' }) - assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) - assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'lo' }) - assembler.apply({ type: 'text.close', item: { id: 'm' } }) - await bind() - expect(await messages(rig)).toEqual(['Hello']) - expect((await rig.row(providerItemId('item', 'm')))?.turnScope).toEqual({ - kind: 'turn', - turnItemId: providerTurnItemId('live') - }) - }) -}) - describe('the open budget counts every provider string a stream keeps', () => { it('refuses a stream whose thread alone is past the budget', async () => { const rig = await openProviderTimelineRig({ schedule: () => () => {} }) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts index c7e66edb98bb..15b5963db5ea 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -31,6 +31,7 @@ import { type ProviderTimelineAssembler, type ProviderTimelineAssemblerDeps } from './provider-timeline-assembler' +import { settleStaleStructuredAgentSessionState } from '../agent-session-wire/structured-agent-session-dead-generation-settlement' import { createLegacyProviderTimelineIdentityScheme, type ProviderTimelineItemFamily @@ -44,23 +45,25 @@ export const NAMESPACE = 'provider-session-1' const scheme = createLegacyProviderTimelineIdentityScheme({ agent: AGENT, sessionId: SESSION }) -/** The journal key the assembler gives a provider-keyed item. */ +/** The journal key the assembler gives a provider-keyed item, or a request of `generation`. */ export function providerItemId( - family: ProviderTimelineItemFamily, + family: ProviderTimelineItemFamily | 'request', key: string, - options: { namespace?: string; incarnation?: number } = {} + options: { namespace?: string; thread?: string; generation?: string; incarnation?: number } = {} ): string { return agentJournalItemKey( - scheme.item({ - namespace: options.namespace ?? NAMESPACE, - family, - key: { source: 'provider', value: key }, - thread: null, - turn: null, - itemClass: 'message', - messageOrdinal: null, - incarnation: options.incarnation ?? 1 - }) + family === 'request' + ? scheme.request({ + generation: options.generation ?? GENERATION, + key, + incarnation: options.incarnation ?? 1 + }) + : scheme.item({ + namespace: options.namespace ?? NAMESPACE, + family, + key: { source: 'provider', value: key }, + thread: options.thread ?? null + }) ) } @@ -139,7 +142,7 @@ export async function closeProviderTimelineRigs(): Promise { } /** An assembler whose sink binds to the rig's journal only when `bind` is called, as a lane that - * starts before its journal opens: it plans without the journal's view. */ + * starts before its journal opens (a resume): it admits events without the journal's view. */ export type UnboundProviderTimelineAssembler = { assembler: ProviderTimelineAssembler bind(): Promise @@ -187,8 +190,12 @@ export type ProviderTimelineRig = { sink: ProviderTimelineSink /** The same journal's event sink, for a lane that writes it directly. */ eventSink: StructuredAgentSessionEventSink - /** A fresh assembler on the same journal, as a restarted host builds. */ - restart(overrides?: Partial): ProviderTimelineAssembler + /** What a restarted host does: the dead-generation sweep settles what the old child left, then + * a new child gets a new assembler in a new generation. */ + restart(overrides?: Partial): Promise + /** Another assembler of the same generation on the same journal, for a test that needs its own + * sink; the rig's own assembler must then stay unused. */ + assemble(overrides?: Partial): ProviderTimelineAssembler rows(): Promise row(itemId: string): Promise /** The turn row of provider turn `turnKey`, or the row whose turn id is `turnKey`. */ @@ -258,12 +265,27 @@ export async function openProviderTimelineRig( const turn = readAgentJournalTurn(item.body) return turn ? [turn] : [] }) + let generations = 1 + const restart = async (more: Partial = {}) => { + generations += 1 + const generation = more.generation ?? `gen-${generations}` + await deferred.drained() + await settleStaleStructuredAgentSessionState({ + journal, + sessionId: SESSION, + fence: 1, + acquisitionGeneration: generation, + deathEvidence: null + }) + return build({ ...more, generation }) + } return { journal, assembler: build(), sink, eventSink: deferred.sink, - restart: build, + restart, + assemble: build, rows, row: async (itemId) => (await rows()).find((item) => item.itemId === itemId), turns, diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts index 69fa01b3f9d1..19a7750b089c 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts @@ -3,6 +3,7 @@ import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal import { closeProviderTimelineRigs, openProviderTimelineRig, + openUnboundProviderTimelineAssembler, providerTurnId, providerTurnItemId } from './provider-timeline-assembler-test-support' @@ -149,9 +150,9 @@ describe('provider timeline turns', () => { expect(rig.assembler.apply({ type: 'turn.end', at: 3_000, state: 'completed' }).dropped).toBe( 'no-turn' ) - // A replayed open of a settled turn neither reopens nor rewrites it. + // An open of a turn the journal holds settled neither reopens nor rewrites it. expect(rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 4_000 }).dropped).toBe( - 'turn-replayed' + 'turn-settled' ) expect(await rig.turn('turn-1')).toMatchObject({ state: 'completed', @@ -161,16 +162,18 @@ describe('provider timeline turns', () => { }) }) - it('never rewrites a settled turn when a restarted host replays its history', async () => { + it('writes nothing for an open admitted before bind whose row the journal already holds settled', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) await rig.rows() - // A new host process has no memory of the turn; only the journal knows it ended. - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ type: 'turn.open', turn: 'turn-1', at: 5_000 }) - restarted.apply({ type: 'turn.end', at: 6_000, state: 'interrupted', outcome: 'cancellation' }) + // Admitted without the journal's view, so only its write can see the row is there. + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + expect( + assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 5_000 }).dropped + ).toBeUndefined() + await bind() expect(await rig.turn('turn-1')).toEqual({ turnId: providerTurnId('turn-1'), state: 'completed', @@ -179,5 +182,8 @@ describe('provider timeline turns', () => { startedAt: 1_000, completedAt: 2_000 }) + // The journal's settlement reaches the state at the next event. + assembler.apply({ type: 'activity', text: 'reading' }) + expect(assembler.openTurnId).toBeNull() }) }) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts index af1918c24b1c..581c9703be00 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -4,56 +4,47 @@ // Division of lifecycle work: // - The adapter translates its dialect, decides when a turn opens, and re-applies an event the sink // refused (the lane runner holds it and pauses reading under backpressure). -// - The assembler plans each event as ONE sink transition. Which row each write lands on, whether -// a replay writes at all, and every change to what the assembler knows (its ledger) are decided -// in the transition's resolvers, at the event's turn in the journal's write queue, against the -// journal as it stands then. So there is one clock: a refused event changed nothing, a write the -// journal rejects changes nothing, and a restart or an evicted cache finds the same rows again. -// - Planning reads a forecast — the ledger plus what admitted events still in the queue will -// change — only to answer `apply` at once: dropped or not, over budget or not, the open turn. -// - The journal keeps what it owns: a person's Stop, a dead generation after a restart, and the -// answer compare-and-set. +// - The assembler admits each event as ONE sink transition. What it knows (`ProviderTimelineState`) +// changes only when the sink admits the event, in admission order, which is the order its own +// rows are written in. Each write decides, when it runs, from what the journal holds then: a +// resume admits events before the sink binds, and the dead-generation sweep lands in between. +// - The journal keeps what other writers own: a person's Stop, a client's answer, the sweep. The +// assembler reads those by key and never copies them. +// - One assembler lives exactly as long as one provider child, so nothing it knows ever has to be +// recovered: a new child is a new assembler, in a new acquisition generation. import type { AgentType } from '../../../shared/agent-session-journal-types' import type { AgentSessionDeltaCoalescerDeps } from '../agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import type { - ProviderTimelineApplyHost, - ProviderTimelineApplyResult, - ProviderTimelineInFlight -} from './provider-timeline-apply-host' import { PROVIDER_TIMELINE_OVER_BUDGET, providerTimelineBudgetAdmits, type ProviderTimelineHold } from './provider-timeline-budget' -import { providerTimelineLedger, type ProviderTimelineContext } from './provider-timeline-context' +import type { ProviderTimelineContext } from './provider-timeline-context' import { decideProviderTimelineEvent, - type ProviderTimelineDecidedEvent, - type ProviderTimelineDecision -} from './provider-timeline-decisions' + type ProviderTimelineDecidedEvent +} from './provider-timeline-decision' import type { ProviderTimelineEvent } from './provider-timeline-event' -import { - createLegacyProviderTimelineIdentityScheme, - type ProviderTimelineIdentityScheme -} from './provider-timeline-identity' -import { ProviderTimelineJoins } from './provider-timeline-joins' +import { createLegacyProviderTimelineIdentityScheme } from './provider-timeline-identity' import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' +import { ProviderTimelineRows } from './provider-timeline-rows' import { ProviderTimelineState } from './provider-timeline-state' import { applyProviderTimelineTextClose, - applyProviderTimelineTextDelta + applyProviderTimelineTextDelta, + type ProviderTimelineApplyResult, + type ProviderTimelineTextHost } from './provider-timeline-text-events' import { ProviderTimelineTextStreams } from './provider-timeline-text-streams' import { planProviderTimelineBarrier, - planProviderTimelineSlots + planProviderTimelineWrites } from './provider-timeline-transition-layout' -export type { ProviderTimelineApplyResult } from './provider-timeline-apply-host' -export type { ProviderTimelineDropRule } from './provider-timeline-decisions' +export type { ProviderTimelineDropRule } from './provider-timeline-decision' +export type { ProviderTimelineApplyResult } from './provider-timeline-text-events' export type ProviderTimelineAssembler = { apply(event: ProviderTimelineEvent): ProviderTimelineApplyResult @@ -68,15 +59,12 @@ export type ProviderTimelineAssemblerDeps = { sink: ProviderTimelineSink sessionId: string agent: AgentType - /** The acquisition: minted keys are unique per generation. */ + /** The acquisition: minted keys and request ids are unique per generation. */ generation: string - /** The provider session whose ids the adapter forwards; the same one again after a restart that - * re-attaches it, so its replays are recognised. */ + /** The provider session whose ids the adapter forwards. */ namespace: string /** The session's own provider thread, for providers that run subagents on threads of their own. */ ownThread?: () => string | null - /** Defaults to the shared `legacy` identity arm. */ - scheme?: ProviderTimelineIdentityScheme coalesceMs?: number schedule?: AgentSessionDeltaCoalescerDeps['schedule'] } @@ -86,174 +74,96 @@ const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } export function createProviderTimelineAssembler( deps: ProviderTimelineAssemblerDeps ): ProviderTimelineAssembler { - const scheme = - deps.scheme ?? - createLegacyProviderTimelineIdentityScheme({ agent: deps.agent, sessionId: deps.sessionId }) - let settlements = 0 const context: ProviderTimelineContext = { sessionId: deps.sessionId, agent: deps.agent, - joins: new ProviderTimelineJoins({ - scheme, + generation: deps.generation, + rows: new ProviderTimelineRows({ + scheme: createLegacyProviderTimelineIdentityScheme({ + agent: deps.agent, + sessionId: deps.sessionId + }), generation: deps.generation, namespace: deps.namespace }), - ledger: new ProviderTimelineState(deps.namespace), - ...(deps.ownThread ? { ownThread: deps.ownThread } : {}), - settlementId: (what) => { - settlements += 1 - return `provider-timeline:${deps.sessionId}:${deps.generation}:${settlements}:${what}` - } + ...(deps.ownThread ? { ownThread: deps.ownThread } : {}) } + const state = new ProviderTimelineState() const streams = new ProviderTimelineTextStreams({ sink: deps.sink, context, - generation: deps.generation, ...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }), ...(deps.schedule ? { schedule: deps.schedule } : {}) }) - let forecast = context.ledger.clone() - const inFlight: ProviderTimelineInFlight[] = [] - - /** The forecast again: the ledger, plus what admitted events not yet run will change. */ - const resync = () => { - forecast = context.ledger.clone() - for (const entry of inFlight) { - if (!entry.executed) { - entry.commit?.(forecast) - } - } - } - - const submit = ( - plan: ProviderTimelinePlan, - entry: ProviderTimelineInFlight - ): StructuredAgentSessionSinkAdmission => { - if (!plan.writes) { - return plan.submit(deps.sink) - } - inFlight.push(entry) - const settle = () => { - const at = inFlight.indexOf(entry) - if (at !== -1) { - inFlight.splice(at, 1) - resync() - } - } - const admission = plan.submit(deps.sink, settle) - if (!admission.accepted) { - inFlight.splice(inFlight.indexOf(entry), 1) - return admission - } - entry.commit?.(forecast) - return admission - } - const admits = (hold: ProviderTimelineHold): boolean => - providerTimelineBudgetAdmits({ - hold, - forecast, - ledger: context.ledger, - streams: streams.open, - journal: deps.sink.journalItems() - }) - - const host: ProviderTimelineApplyHost = { - context, + const textHost = (journal: ProviderTimelineTextHost['journal']): ProviderTimelineTextHost => ({ + sink: deps.sink, + state, streams, - forecast: () => forecast, - journal: () => deps.sink.journalItems(), - admits, - submit - } + journal, + admits: (hold) => admits(hold, journal) + }) - const applyDecided = (event: ProviderTimelineDecidedEvent): ProviderTimelineApplyResult => { - const journal = deps.sink.journalItems() + const admits = (hold: ProviderTimelineHold, journal: ProviderTimelineTextHost['journal']) => + providerTimelineBudgetAdmits({ hold, state, streams: streams.open, journal }) + + const applyDecided = ( + event: ProviderTimelineDecidedEvent, + journal: ProviderTimelineTextHost['journal'] + ): ProviderTimelineApplyResult => { + const serials = state.serials() const decision = decideProviderTimelineEvent( - { context, state: forecast, journal, execute: false }, + { context, state, journal, serial: serials.next }, event ) if (decision.dropped) { return { admission: ADMITTED, dropped: decision.dropped } } - if (decision.hold && !admits(decision.hold)) { + if (decision.hold && !admits(decision.hold, journal)) { return { admission: PROVIDER_TIMELINE_OVER_BUDGET } } const plan = new ProviderTimelinePlan() - const entry: ProviderTimelineInFlight = { executed: false, commit: decision.commit } - let executed: ProviderTimelineDecision | null = null - // A message boundary is the event's only when its own decision, at execution, wrote. - const landed = () => executed !== null && !executed.dropped && executed.write !== null - planProviderTimelineBarrier(host, plan, event, decision, landed) - // The event's decision on the ledger, taken once, by its first slot to run. - const decide = (at: StructuredAgentSessionTransitionJournal) => { - if (!executed) { - const ledger = providerTimelineLedger(context, at) - executed = decideProviderTimelineEvent( - { context, state: ledger, journal: at, execute: true }, - event - ) - entry.executed = true - executed.commit?.(ledger) - } - return executed.dropped ? null : executed - } - planProviderTimelineSlots(host, plan, event, decide) - if ( - event.type === 'turn.open' || - event.type === 'turn.end' || - event.type === 'session.ended' || - event.type === 'session.reset' - ) { + planProviderTimelineBarrier({ streams, state, context }, plan, event, decision) + planProviderTimelineWrites(context, plan, decision, serials.next) + plan.onAdmitted(() => { + decision.commit?.(state) + serials.commit() + }) + if (event.type === 'turn.open' || event.type === 'turn.end' || event.type === 'session.ended') { plan.onAdmitted(() => deps.sink.setActivity?.(null)) } - return { admission: submit(plan, entry) } + return { admission: plan.submit(deps.sink) } } const apply = (event: ProviderTimelineEvent): ProviderTimelineApplyResult => { const journal = deps.sink.journalItems() if (journal) { - forecast.reconcile(journal) + state.reconcile(journal) } switch (event.type) { case 'text.delta': - return applyProviderTimelineTextDelta(host, event) + return applyProviderTimelineTextDelta(textHost(journal), event) case 'text.close': - return applyProviderTimelineTextClose(host, event) + return applyProviderTimelineTextClose(textHost(journal), event) case 'activity': { - const open = forecast.open - if (forecast.ended || !open) { - return { admission: ADMITTED, dropped: forecast.ended ? 'session-ended' : 'no-turn' } + const open = state.open + if (state.ended || !open) { + return { admission: ADMITTED, dropped: state.ended ? 'session-ended' : 'no-turn' } } deps.sink.setActivity?.( event.text === null ? null : { turnId: open.turnId, text: event.text } ) return { admission: ADMITTED } } - case 'turn.open': - return applyDecided( - event.turn === undefined ? { ...event, minted: context.joins.mint('t') } : event - ) - case 'provider.frame': - return applyDecided({ ...event, minted: context.joins.mint('f') }) - case 'input.accepted': - case 'turn.end': - case 'item.open': - case 'item.update': - case 'item.close': - case 'request.open': - case 'request.withdrawn': - case 'context.usage': - case 'session.ended': - case 'session.reset': - return applyDecided(event) + default: + return applyDecided(event, journal) } } return { apply, get openTurnId() { - return forecast.open?.turnId ?? null + return state.open?.turnId ?? null }, flush: () => streams.flush(), dispose: () => streams.dispose() diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts index 864a193818fa..a097bf689d69 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts @@ -1,12 +1,11 @@ // One admission budget for everything the assembler holds open: running items, pending requests -// and open text streams. It is derived, not booked: the open set is the forecast's obligations -// plus the open streams, one entry per item (an item and its stream share its join reference) and -// one per request key whatever its incarnation. Before refusing, the set is re-derived from the -// journal, so an answer a client gave or a row another writer settled frees its room. Past the -// budget an event is refused as `failed`, which ends the session the way a failed journal write -// does, and the journal-derived dead-generation settlement closes whatever it left open. +// and open text streams. The open set is the state's open items plus the open streams, one entry +// per item (an item and its stream share its row id) and one per request key whatever its +// incarnation. Before refusing, each entry is checked against the journal by key, so an answer a +// client gave or a row another writer settled frees its room. Past the budget an event is refused +// as `failed`, which ends the session the way a failed journal write does, and the journal-derived +// dead-generation settlement closes whatever it left open. -import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import type { ProviderTimelineState } from './provider-timeline-state' @@ -23,21 +22,21 @@ export type ProviderTimelineHold = { key: string; bytes: number } export function providerTimelineBudgetAdmits(input: { hold: ProviderTimelineHold - forecast: ProviderTimelineState - ledger: ProviderTimelineState + state: ProviderTimelineState streams: readonly ProviderTimelineHold[] journal: StructuredAgentSessionTransitionJournal | null }): boolean { if (fits(input)) { return true } - // The fold is never behind the ledger, so what it shows settled is settled in both. - for (const state of [input.forecast, input.ledger]) { - for (const [key, obligation] of state.obligations) { - const body = obligation.itemId ? input.journal?.itemBody(obligation.itemId) : null - if (body && !requiresTerminalSettlement(body)) { - state.obligations.delete(key) - } + const { journal, state } = input + if (!journal) { + return false + } + // Another writer's fact, read by key: what it settled is settled. + for (const key of state.items.keys()) { + if (state.settledInJournal(key, journal)) { + state.items.delete(key) } } return fits(input) @@ -45,12 +44,14 @@ export function providerTimelineBudgetAdmits(input: { function fits(input: { hold: ProviderTimelineHold - forecast: ProviderTimelineState + state: ProviderTimelineState streams: readonly ProviderTimelineHold[] }): boolean { const open = new Map() - for (const [key, obligation] of input.forecast.obligations) { - open.set(key, obligation.bytes) + for (const [key, entry] of input.state.items) { + if (!entry.closed) { + open.set(key, entry.bytes) + } } for (const stream of input.streams) { open.set(stream.key, Math.max(open.get(stream.key) ?? 0, stream.bytes)) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts index 16cdee07da9e..e65c134ad888 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts @@ -1,34 +1,30 @@ -// What every planner and resolver of one assembler shares, and where a joined row goes. +// What every decision of one assembler shares, and where a joined row goes. import type { AgentJournalProducerLinkage, + AgentJournalTurnScope, AgentType } from '../../../shared/agent-session-journal-types' -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import type { ProviderTimelineJoin } from './provider-timeline-event' -import type { ProviderTimelineJoins, ProviderTimelinePlacement } from './provider-timeline-joins' +import { providerKey, type ProviderTimelineRows } from './provider-timeline-rows' import type { ProviderTimelineState } from './provider-timeline-state' export type ProviderTimelineContext = { sessionId: string agent: AgentType - joins: ProviderTimelineJoins - /** The truth decisions run on; resolvers only. */ - ledger: ProviderTimelineState + generation: string + rows: ProviderTimelineRows /** The session's own provider thread; a join naming another thread is subagent work. */ ownThread?: () => string | null - /** A settlement id no other settlement of this journal shares. */ - settlementId(what: string): string } -/** The ledger, with its open turn taken from the journal before its first decision runs, and - * ended when another writer of the journal ended it. */ -export function providerTimelineLedger( +/** A settlement id no other settlement of this journal shares. */ +export function providerTimelineSettlementId( context: ProviderTimelineContext, - journal: StructuredAgentSessionTransitionJournal -): ProviderTimelineState { - context.ledger.reconcile(journal) - return context.ledger + serial: number, + what: string +): string { + return `provider-timeline:${context.sessionId}:${context.generation}:${serial}:${what}` } /** The turn a new row joins: the one the provider names (subagent work joins the open turn while @@ -37,21 +33,16 @@ export function providerTimelinePlacement( context: ProviderTimelineContext, state: ProviderTimelineState, join: ProviderTimelineJoin | undefined -): ProviderTimelinePlacement { +): AgentJournalTurnScope { const thread = join?.thread ?? null if (join?.turn === undefined) { - return { thread, turn: state.open?.address.key ?? null, scope: state.scope } + return state.scope } - const key = { source: 'provider', value: join.turn } as const const own = context.ownThread?.() ?? null - const subagent = thread !== null && own !== null && thread !== own - return { - thread, - turn: key, - scope: subagent - ? state.scope - : { kind: 'turn', turnItemId: context.joins.turn(key, state.namespace).itemId } + if (thread !== null && own !== null && thread !== own) { + return state.scope } + return { kind: 'turn', turnItemId: context.rows.turn(providerKey(join.turn)).itemId } } /** What an entry the assembler holds open costs: its body, its producer, and every provider diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts index ee13faf44a36..bd1274934676 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -1,4 +1,10 @@ -// The shape of a decision on one non-text event, and the reads every rule shares. +// What a non-text event does, in two parts that never share a clock. +// +// Admission decides from the state (and the journal's rows by key, when bound) whether the event +// is dropped, what it holds open, and how it changes the state; it writes and allocates nothing. +// Each write's resolver decides, when the write runs, what the journal holds then: a resume admits +// events before the sink binds, and the dead-generation sweep lands between admission and the +// write, so every journal-dependent choice is made there. import type { AgentJournalItemBody, @@ -10,92 +16,107 @@ import type { StructuredAgentSessionTransitionJournal } from '../agent-session-w import type { ProviderTimelineHold } from './provider-timeline-budget' import type { ProviderTimelineContext } from './provider-timeline-context' import type { ProviderTimelineEvent } from './provider-timeline-event' -import type { ProviderTimelineKey } from './provider-timeline-identity' -import type { ProviderTimelineTurnRef } from './provider-timeline-joins' import { - providerTimelineSettlement, - type ProviderTimelineTurnEnd -} from './provider-timeline-settlement' + decideFrame, + decideItem, + decideRequest, + decideWithdrawal +} from './provider-timeline-item-decisions' import type { ProviderTimelineState } from './provider-timeline-state' +import { + decideContextUsage, + decideHistory, + decideInput, + decideSessionEnd, + decideTurnEnd, + decideTurnOpen +} from './provider-timeline-turn-decisions' -/** Why an event wrote nothing. Each is a grammar rule the adapter broke or a replay it repeated. */ +/** Why an event wrote nothing. Each is a grammar rule the adapter broke or a fact already held. */ export type ProviderTimelineDropRule = | 'session-ended' | 'turn-duplicate' - | 'turn-replayed' + | 'turn-settled' | 'turn-unknown' | 'no-turn' | 'item-settled' - | 'item-replayed' | 'request-duplicate' - | 'request-replayed' | 'request-unknown' | 'stream-unknown' | 'stream-mismatch' -/** A non-text event with the keys planning minted for it, so execution names the same ones. */ - -/** A non-text event with the keys planning minted for it, so execution names the same ones. */ export type ProviderTimelineDecidedEvent = Exclude< ProviderTimelineEvent, { type: 'text.delta' | 'text.close' | 'activity' } -> & { minted?: ProviderTimelineKey } +> + +type Journal = StructuredAgentSessionTransitionJournal export type ProviderTimelineResolvedWrite = { identity: AgentJournalItemIdentity body: AgentJournalItemBody - options?: StructuredAgentSessionItemAppendOptions +} + +/** One row write: where it goes is fixed at admission, what it says is resolved when it runs. */ +export type ProviderTimelineItemWrite = { + reservedBytes: number + lifecycle: boolean + /** A new row's turn is admission's placement; an existing row keeps its own. */ + options: StructuredAgentSessionItemAppendOptions + resolve: (journal: Journal) => ProviderTimelineResolvedWrite | null } export type ProviderTimelineDecision = { dropped?: ProviderTimelineDropRule - /** What the event would hold open, for the budget. */ + /** What the event holds open, for the budget. */ hold?: ProviderTimelineHold - /** The event's change to what is known: the forecast's at admission, the ledger's at execution. */ + /** The event's change to the state, made when the sink admits it. */ commit?: (state: ProviderTimelineState) => void - /** Execution only: the settlement step's mutations and the item step's write. */ - settle?: readonly JournalLifecycleMutationInput[] - write?: ProviderTimelineResolvedWrite | null - /** The item the event closes, whose stream it releases. */ + /** The settlement the event owes, read from the journal when it runs. */ + settle?: { what: string; resolve: (journal: Journal) => readonly JournalLifecycleMutationInput[] } + writes?: readonly ProviderTimelineItemWrite[] + /** The streamed item whose text this event's full snapshot replaces. */ closes?: string } export type ProviderTimelineDecisionInput = { context: ProviderTimelineContext state: ProviderTimelineState - journal: StructuredAgentSessionTransitionJournal | null - /** Execution: place rows and compute writes. Planning never does. */ - execute: boolean -} - -export const providerKey = (value: string): ProviderTimelineKey => ({ source: 'provider', value }) - -export function settledTool(body: AgentJournalItemBody | null): boolean { - return body?.kind === 'tool-call' && body.state !== 'running' + /** The journal at admission; null before the sink binds. */ + journal: Journal | null + /** Serials this event takes, committed with it. */ + serial: () => number } -export function runningTool(body: AgentJournalItemBody): boolean { - return body.kind === 'tool-call' && body.state === 'running' -} - -export function pendingPrompt(body: AgentJournalItemBody | null): boolean { - return ( - (body?.kind === 'approval' || body?.kind === 'question') && body.resolution.state === 'pending' - ) -} - -export function turnOf(scope: { kind: string; turnItemId?: string }): string | null { - return scope.kind === 'turn' ? (scope.turnItemId ?? null) : null -} - -/** A turn's journal-derived settlement, at execution. */ -export function settlementOf( +export function decideProviderTimelineEvent( input: ProviderTimelineDecisionInput, - turn: ProviderTimelineTurnRef, - end: ProviderTimelineTurnEnd -): readonly JournalLifecycleMutationInput[] | undefined { - if (!input.execute || !input.journal) { - return undefined + event: ProviderTimelineDecidedEvent +): ProviderTimelineDecision { + if (input.state.ended) { + return { dropped: 'session-ended' } + } + switch (event.type) { + case 'input.accepted': + return decideInput(input, event) + case 'input.history': + return decideHistory(input, event) + case 'turn.open': + return decideTurnOpen(input, event) + case 'turn.end': + return decideTurnEnd(input, event) + case 'item.open': + case 'item.update': + case 'item.close': + return decideItem(input, event) + case 'request.open': + return decideRequest(input, event) + case 'request.withdrawn': + return decideWithdrawal(input, event) + case 'context.usage': + return decideContextUsage(input, event) + case 'provider.frame': + return decideFrame(input, event) + case 'session.ended': + return decideSessionEnd(input, event) } - return providerTimelineSettlement(input.journal, { turnItemId: turn.itemId }, [turn], end) } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts deleted file mode 100644 index 7f03e7267c00..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts +++ /dev/null @@ -1,76 +0,0 @@ -// What each non-text event does: one rule set, run twice. -// -// Planning runs it on the forecast and the journal as it stands, to answer `apply` (dropped or -// not, and what it would hold open) without writing or allocating anything. Execution runs it -// again in the event's first resolver, on the ledger and the fold at that point in the journal's -// write queue: only then are rows placed, ordinals and incarnations taken, and the ledger changed. -// So when the two disagree — before bind, after a restart, behind a replay the journal rejects — -// the journal's answer is the one that lands, and the ledger follows it. - -import { - decideFrame, - decideItem, - decideRequest, - decideWithdrawal -} from './provider-timeline-item-decisions' -import type { - ProviderTimelineDecidedEvent, - ProviderTimelineDecision, - ProviderTimelineDecisionInput -} from './provider-timeline-decision' -import { - decideContextUsage, - decideInput, - decideSessionEnd, - decideTurnEnd, - decideTurnOpen -} from './provider-timeline-turn-decisions' - -export type { - ProviderTimelineDecidedEvent, - ProviderTimelineDecision, - ProviderTimelineDropRule -} from './provider-timeline-decision' - -export function decideProviderTimelineEvent( - input: ProviderTimelineDecisionInput, - event: ProviderTimelineDecidedEvent -): ProviderTimelineDecision { - if (input.state.ended && event.type !== 'session.reset') { - return { dropped: 'session-ended' } - } - switch (event.type) { - case 'input.accepted': - return decideInput(input, event) - case 'turn.open': - return decideTurnOpen(input, event) - case 'turn.end': - return decideTurnEnd(input, event) - case 'item.open': - case 'item.update': - case 'item.close': - return decideItem(input, event) - case 'request.open': - return decideRequest(input, event) - case 'request.withdrawn': - return decideWithdrawal(input, event) - case 'context.usage': - return decideContextUsage(input, event) - case 'provider.frame': - return decideFrame(input, event) - case 'session.ended': - return decideSessionEnd(input, event.verdict, (state) => { - state.ended = true - state.endSession() - }) - case 'session.reset': - // Nothing ended the old session's open work, so it is lost, not interrupted; the resolver - // finds nothing when an end already settled it. - return decideSessionEnd(input, { state: 'unverifiable' }, (state) => { - state.reset(event.namespace) - if (input.execute) { - input.context.joins.reset(event.namespace) - } - }) - } -} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts index b3f01ff9f7e4..a745709210cd 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -4,30 +4,34 @@ // provider traffic into these semantic events and never writes a journal row or mints a journal // identity: every key here is the provider's own id (a turn, a tool call, a message, a thread), // or a name the adapter keeps stable for something the provider does not name. The assembler -// resolves those joins to rows, scopes rows to turns, and owns the turn and item rules in -// `agent-session-journal-types.ts`; the journal decides, at each write, what a replay changes. +// spells those keys as row ids, scopes rows to turns, and owns the turn and item rules in +// `agent-session-journal-types.ts`. // // Rules an adapter can rely on: +// - One assembler lives exactly as long as one provider child: a new process is a new assembler, +// with a new acquisition generation. // - Only `turn.open` opens a turn. Items, text and requests never do: one that arrives with no // turn open and names none is written as a thread row. An adapter whose provider starts work on // its own (a background wake, auto-compaction) decides that a turn began and says `turn.open` // before that work's items. // - `join.turn` attaches a row to the turn the provider says it belongs to, open or not. -// - A turn settles once, with only the provider's verdict; the journal refuses a second settlement -// and a replayed open, even after a restart. +// - A turn settles once, with only the provider's verdict. A row the journal already holds is +// never written back to an earlier state: a settled turn stays settled, a settled tool never +// runs again, an answered request stays answered. +// - Saved history the provider replays goes only into an empty journal (an adopted session), as +// ordinary events with the provider's own ids, so re-running an interrupted adoption writes the +// same rows again. // - An event the sink refused changed nothing. Re-apply the same event to retry it (after // `backpressure`); `failed` and `closed` are final. // - A provider item is (thread, id): the same id on another thread is another item. Text and // full snapshots of one id are one row, and its close settles it for both. -// - `dropped` is the verdict when the event was applied. The journal has the last word when the -// event's writes run: a replay the journal already holds writes nothing even if it was taken. -// - After `session.ended` every event is dropped until `session.reset`. Reset whenever the -// provider's ids may repeat (a new process): its ids then name new rows. +// - After `session.ended` every event is dropped. import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' import type { AgentJournalApprovalItem, AgentJournalItemBody, + AgentJournalMessageItem, AgentJournalProducerLinkage, AgentJournalQuestionItem, AgentJournalTurnOutcome @@ -66,13 +70,20 @@ type Joined = { join?: ProviderTimelineJoin } export type ProviderTimelineEvent = /** Orca's send reached the provider. It names the open turn when nothing opened that one, else - * the next. `join.item`: the provider's id for its echo of the send, which takes that message's - * place among its turn's messages and writes no row (the send's own row is the bubble). */ + * the next; `join.turn` names the turn it opens. The send's own row is the bubble. */ | { type: 'input.accepted' clientMessageId: string requestedAt: number - join?: ProviderTimelineJoin & { item?: string } + join?: ProviderTimelineJoin + } + /** A user message from the provider's saved history (an adopted session has no Orca send): its + * own row, keyed by `item`, and the message that opened `join.turn`. */ + | { + type: 'input.history' + item: string + body: AgentJournalMessageItem + join: ProviderTimelineJoin & { turn: string } } /** A turn began. `turn` is the provider's turn id when it has one; the assembler mints one otherwise. */ | { type: 'turn.open'; turn?: string; at: number } @@ -120,6 +131,3 @@ export type ProviderTimelineEvent = | ({ type: 'provider.frame'; frameKind: string; payload: unknown } & Joined) /** The provider child is gone. The verdict is what the host can prove about its end. */ | { type: 'session.ended'; verdict: StructuredAgentSessionTurnVerdict } - /** A new provider session began; its ids live in `namespace`. Work still open is settled first, - * as lost (`unverifiable`), since nothing ended it. */ - | { type: 'session.reset'; namespace: string } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.test.ts new file mode 100644 index 000000000000..6242af3998ff --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + createLegacyProviderTimelineIdentityScheme, + providerTimelineKeyPart, + spellProviderTimelineKey +} from './provider-timeline-identity' + +const scheme = createLegacyProviderTimelineIdentityScheme({ agent: 'grok', sessionId: 's1' }) +const provider = (value: string) => ({ source: 'provider', value }) as const + +describe('provider timeline identity spelling', () => { + it('escapes a key so it cannot forge another part', () => { + expect(providerTimelineKeyPart('a:b/c')).toBe('a%3Ab%2Fc') + expect(spellProviderTimelineKey('ns', provider('x:y'))).not.toBe( + spellProviderTimelineKey('ns:x', provider('y')) + ) + }) + + it('bounds a long key by a digest, so two long keys with one prefix stay apart', () => { + const head = 'k'.repeat(400) + const one = providerTimelineKeyPart(`${head}1`) + const two = providerTimelineKeyPart(`${head}2`) + expect(Buffer.byteLength(one, 'utf8')).toBeLessThanOrEqual(256) + expect(one).not.toBe(two) + }) + + it('spells an item per thread, and a minted key apart from any provider one', () => { + const item = (thread: string | null) => + agentJournalItemKey( + scheme.item({ namespace: 'ns', family: 'item', key: provider('m1'), thread }) + ) + expect(new Set([item(null), item('root'), item('child')]).size).toBe(3) + expect(spellProviderTimelineKey('ns', { source: 'minted', value: 'gen-1:s1' })).toBe( + 'm:gen-1%3As1' + ) + }) + + it('spells a request in the acquisition that asked it, with its incarnation', () => { + const request = (generation: string, incarnation: number) => + scheme.request({ generation, key: '0', incarnation }) + expect(request('gen-1', 1)).toMatchObject({ provider: 'legacy', recordId: 'request:g:gen-1:0' }) + expect(request('gen-1', 2)).toMatchObject({ recordId: 'request:g:gen-1:0#2' }) + expect(agentJournalItemKey(request('gen-2', 1))).not.toBe( + agentJournalItemKey(request('gen-1', 1)) + ) + }) + + it('keeps turn rows on the record prefix every lane writes', () => { + const turn = { namespace: 'ns', key: provider('t1') } + expect(scheme.turn(turn)).toMatchObject({ recordId: 'turn-lifecycle:p:ns:t1' }) + expect(scheme.turnId(turn)).toBe('p:ns:t1') + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts new file mode 100644 index 000000000000..6dd47f03ec53 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts @@ -0,0 +1,106 @@ +// How the assembler's keys become persisted journal identities. +// +// The assembler decides WHICH rows are the same row; a scheme only spells them. It must be pure, +// and it is the one place a provider's persisted identity shape lives. Every provider on the +// assembler spells its rows in the existing `legacy` arm, so no row shape is new. + +import type { + AgentJournalItemIdentity, + AgentType +} from '../../../shared/agent-session-journal-types' +import { boundPayload, digestPayload } from '../agent-session-journal/journal-payload-bounds' + +/** A key the provider vouched for, or one the assembler minted because it named nothing. + * A minted value is unique per acquisition, so it never needs a namespace. */ +export type ProviderTimelineKey = { source: 'provider' | 'minted'; value: string } + +/** Streamed text and full item snapshots share `item`, so a provider-named message is one row + * however it arrives; fallback frames never share its key space. */ +export type ProviderTimelineItemFamily = 'item' | 'frame' + +export type ProviderTimelineTurnAddress = { + /** The provider session whose ids the key belongs to. */ + namespace: string + key: ProviderTimelineKey +} + +export type ProviderTimelineItemAddress = ProviderTimelineTurnAddress & { + family: ProviderTimelineItemFamily + /** The provider thread it came from; null when the provider named none. */ + thread: string | null +} + +/** A question the provider asked under its request id. JSON-RPC ids restart with each provider + * process, so a request is spelled in the acquisition that asked it. */ +export type ProviderTimelineRequestAddress = { + generation: string + key: string + /** 1 for the first request under the key in this acquisition; a reused key takes the next. */ + incarnation: number +} + +export type ProviderTimelineIdentityScheme = { + turn(address: ProviderTimelineTurnAddress): AgentJournalItemIdentity + /** The id the turn row carries and a client's Stop names. */ + turnId(address: ProviderTimelineTurnAddress): string + item(address: ProviderTimelineItemAddress): AgentJournalItemIdentity + request(address: ProviderTimelineRequestAddress): AgentJournalItemIdentity +} + +const MAX_KEY_PART_BYTES = 256 + +/** A provider id as a bounded identity part: escaped so `:` cannot forge another part, and + * digest-suffixed when long so two long ids never share a prefix-only spelling. */ +export function providerTimelineKeyPart(value: string): string { + const encoded = encodeURIComponent(value) + if (Buffer.byteLength(encoded, 'utf8') <= MAX_KEY_PART_BYTES) { + return encoded + } + const suffix = `#${digestPayload(value).slice(0, 24)}` + const head = boundPayload(encoded, { + inlineHeadBytes: MAX_KEY_PART_BYTES - Buffer.byteLength(suffix, 'utf8') + }).head + return `${head}${suffix}` +} + +/** A key as one bounded string inside its namespace (and thread, for a per-thread key). */ +export function spellProviderTimelineKey( + namespace: string, + key: ProviderTimelineKey, + thread: string | null = null +): string { + // A provider's item ids are its own per thread, so a subagent thread's ids are spelled apart. + const scope = thread === null ? '' : `${providerTimelineKeyPart(thread)}/` + return key.source === 'provider' + ? `p:${providerTimelineKeyPart(namespace)}:${scope}${providerTimelineKeyPart(key.value)}` + : `m:${providerTimelineKeyPart(key.value)}` +} + +/** The existing `legacy` arm. Turn rows keep the `turn-lifecycle:` record prefix the other lanes + * write; provider keys are spelled inside their namespace, and apart from minted ones. */ +export function createLegacyProviderTimelineIdentityScheme(input: { + agent: AgentType + sessionId: string +}): ProviderTimelineIdentityScheme { + const identity = (recordId: string): AgentJournalItemIdentity => ({ + provider: 'legacy', + agent: input.agent, + sessionId: input.sessionId, + recordId + }) + return { + turn: (address) => + identity(`turn-lifecycle:${spellProviderTimelineKey(address.namespace, address.key)}`), + turnId: (address) => spellProviderTimelineKey(address.namespace, address.key), + item: (address) => + identity( + `${address.family}:${spellProviderTimelineKey(address.namespace, address.key, address.thread)}` + ), + request: (address) => + identity( + `request:g:${providerTimelineKeyPart(address.generation)}:${providerTimelineKeyPart(address.key)}${ + address.incarnation > 1 ? `#${address.incarnation}` : '' + }` + ) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts index d5eb0ffb62ca..e3d9ff8ec461 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts @@ -1,22 +1,67 @@ -// What item, request and frame events do, decided on the forecast and again on the ledger. +// What item, request and frame events do: admitted on the state, written against the journal. -import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody +} from '../../../shared/agent-session-journal-types' import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' +import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' +import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { unhandledProviderFrameJournalItem } from '../agent-session-wire/unhandled-provider-frame' import { relightsProviderTimelineBackgroundTask } from './provider-timeline-background-tasks' import { providerTimelineEntryBytes, providerTimelinePlacement } from './provider-timeline-context' +import type { + ProviderTimelineDecidedEvent, + ProviderTimelineDecision, + ProviderTimelineDecisionInput +} from './provider-timeline-decision' import { - pendingPrompt, providerKey, - runningTool, - settledTool, + providerTimelineTurnRowState, turnOf, - type ProviderTimelineDecidedEvent, - type ProviderTimelineDecision, - type ProviderTimelineDecisionInput -} from './provider-timeline-decision' -import { providerTimelineItemClass, type ProviderTimelineKey } from './provider-timeline-identity' -import type { ProviderTimelineItemJoin } from './provider-timeline-joins' + type ProviderTimelineRowId +} from './provider-timeline-rows' +import type { ProviderTimelineRequestBody } from './provider-timeline-event' +import type { ProviderTimelineOpenItem } from './provider-timeline-state' + +type Journal = StructuredAgentSessionTransitionJournal +type ItemChange = 'open' | 'update' | 'close' + +function settledTool(body: AgentJournalItemBody | null): boolean { + return body?.kind === 'tool-call' && body.state !== 'running' +} + +function pendingPrompt(body: AgentJournalItemBody | null): body is ProviderTimelineRequestBody { + return ( + (body?.kind === 'approval' || body?.kind === 'question') && body.resolution.state === 'pending' + ) +} + +/** Whether the row the journal holds refuses this write: a settled tool never runs again and keeps + * its first terminal body, a settled background task is never relit, and a turn that is over + * takes no work that waits on a settlement it already ran. */ +function refusesItemWrite( + journal: Journal, + row: ProviderTimelineRowId, + change: ItemChange, + body: AgentJournalItemBody, + placed: string | null +): boolean { + const held = journal.item(row.itemId) + const turn = held ? turnOf(held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE) : placed + if (settledTool(held?.body ?? null) && (change === 'close' || requiresTerminalSettlement(body))) { + return true + } + if (relightsProviderTimelineBackgroundTask(held?.body ?? null, body)) { + return true + } + return ( + requiresTerminalSettlement(body) && + turn !== null && + providerTimelineTurnRowState(journal, turn) === 'settled' + ) +} export function decideItem( input: ProviderTimelineDecisionInput, @@ -25,37 +70,19 @@ export function decideItem( const { state, journal, context } = input const change = event.type === 'item.open' ? 'open' : event.type === 'item.update' ? 'update' : 'close' - const join: ProviderTimelineItemJoin = { - family: 'item', - key: providerKey(event.item), - thread: event.join?.thread ?? null - } - const ref = context.joins.reference(join, state.namespace) - const found = context.joins.find(join, journal, state.namespace) - const held = found ? (journal?.itemBody(found.itemId) ?? null) : null - // This run closed it: an open reopens the same row, a second close is a repeat. - const closed = state.closed.has(ref) - if (change === 'open' && !closed && held) { - return { dropped: 'item-replayed' } - } - if (change === 'update' && runningTool(event.body) && (closed || settledTool(held))) { - return { dropped: 'item-settled' } - } - if (change === 'close' && (closed || settledTool(held))) { - return { dropped: 'item-settled' } - } - if (change !== 'open' && relightsProviderTimelineBackgroundTask(held, event.body)) { + const row = context.rows.item('item', providerKey(event.item), event.join?.thread ?? null) + const key = row.itemId + const closed = state.items.get(key)?.closed === true + const scope = providerTimelinePlacement(context, state, event.join) + const placed = turnOf(scope) + if ( + (closed && + (change === 'close' || (change === 'update' && requiresTerminalSettlement(event.body)))) || + (journal && refusesItemWrite(journal, row, change, event.body, placed)) + ) { return { dropped: 'item-settled' } } const obligation = change !== 'close' && requiresTerminalSettlement(event.body) - const placement = providerTimelinePlacement(context, state, event.join) - const row = - found ?? - (input.execute && journal - ? context.joins.place(join, providerTimelineItemClass(event.body), placement, journal) - : null) - const scope = row?.scope ?? placement.scope - const turnItemId = turnOf(scope) const bytes = providerTimelineEntryBytes({ key: event.item, join: event.join, @@ -63,94 +90,81 @@ export function decideItem( producer: event.producer }) return { - ...(obligation ? { hold: { key: ref, bytes } } : {}), - write: row && { - identity: row.identity, - body: event.body, - options: { - ...event.producer, - turnScope: row.scope, - ...(row.ref === undefined ? {} : { providerItemRef: row.ref }) + ...(obligation ? { hold: { key, bytes } } : {}), + writes: [ + { + reservedBytes: estimateStructuredAgentSessionItemBytes(row.identity, event.body), + lifecycle: change !== 'update', + options: { ...event.producer, turnScope: scope }, + resolve: (at) => + refusesItemWrite(at, row, change, event.body, placed) + ? null + : { identity: row.identity, body: event.body } } - }, - ...(change === 'close' ? { closes: ref } : {}), + ], + ...(change === 'close' ? { closes: key } : {}), commit: (next) => { - if (obligation) { - next.obligations.set(ref, { itemId: row?.itemId ?? null, turnItemId, bytes }) - } else { - next.obligations.delete(ref) - } if (change === 'close') { - next.closed.set(ref, turnItemId) - } else if (change === 'open') { - next.closed.delete(ref) + next.close(key, placed) + } else if (obligation) { + next.items.set(key, { kind: 'item', row, turnItemId: placed, bytes, closed: false }) + } else if (change === 'open' || next.items.get(key)?.closed !== true) { + next.items.delete(key) } } } } -export function requestRef(input: ProviderTimelineDecisionInput, key: ProviderTimelineKey): string { - return input.context.joins.reference( - { family: 'request', key, thread: null }, - input.state.namespace - ) -} +const requestKey = (request: string) => `request:${request}` export function decideRequest( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { const { state, journal, context } = input - const key = providerKey(event.request) - const ref = requestRef(input, key) - const current = context.joins.request(key, journal, state.namespace) - const held = current ? (journal?.itemBody(current.itemId) ?? null) : null - // Pending in the journal, or opened here and not landed yet: the same request again. - if (pendingPrompt(held) || (state.obligations.has(ref) && held === null)) { + const key = requestKey(event.request) + // Pending here, unless a client's answer already settled it in the journal. + if (state.items.has(key) && !(journal && state.settledInJournal(key, journal))) { return { dropped: 'request-duplicate' } } - // The turn this open lands in decides whether it repeats its predecessor: the same key again - // in a turn that is over is that turn's replay; in another, live turn it is a new request. - const placement = providerTimelinePlacement(context, state, event.join) - const target = turnOf(placement.scope) - const previous = current ? turnOf(current.scope) : null - if ( - held && - previous !== null && - state.status({ itemId: previous }, journal) === 'settled' && - (target === null || - target === previous || - state.status({ itemId: target }, journal) === 'settled') - ) { - return { dropped: 'request-replayed' } - } - const row = - input.execute && journal - ? context.joins.nextRequest(key, event.body.kind, placement, journal) - : null + const scope = providerTimelinePlacement(context, state, event.join) + const turn = turnOf(scope) const bytes = providerTimelineEntryBytes({ key: event.request, join: event.join, body: event.body, producer: event.producer }) + const opened: ProviderTimelineOpenItem = { + kind: 'request', + row: null, + turnItemId: turn, + bytes, + closed: false + } return { - hold: { key: ref, bytes }, - // A row already there is a client's answer or a replay; neither is overwritten. - write: - row && journal?.itemBody(row.itemId) === null - ? { - identity: row.identity, - body: event.body, - options: { ...event.producer, turnScope: row.scope, lifecycle: true } + hold: { key, bytes }, + writes: [ + { + reservedBytes: estimateStructuredAgentSessionItemBytes( + context.rows.widestRequest(event.request).identity, + event.body + ), + lifecycle: true, + options: { ...event.producer, turnScope: scope, lifecycle: true }, + resolve: (at) => { + // A reused id takes the next incarnation; a row already there is never overwritten. + const row = context.rows.nextRequest(event.request, at) + opened.row = row + // A turn another writer ended while the open was queued asks nothing more. + if (turn !== null && providerTimelineTurnRowState(at, turn) === 'settled') { + return null } - : null, - commit: (next) => - next.obligations.set(ref, { - itemId: row?.itemId ?? null, - turnItemId: turnOf(row?.scope ?? placement.scope), - bytes - }) + return { identity: row.identity, body: event.body } + } + } + ], + commit: (next) => next.items.set(key, opened) } } @@ -158,34 +172,34 @@ export function decideWithdrawal( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { - const { state, journal } = input - const key = providerKey(event.request) - const ref = requestRef(input, key) - // The latest incarnation the journal holds, however long ago this run (or another) opened it. - const current = input.context.joins.request(key, journal, state.namespace) - const held = current ? (journal?.itemBody(current.itemId) ?? null) : null - if (!pendingPrompt(held) && !(state.obligations.has(ref) && held === null)) { + const key = requestKey(event.request) + const opened = input.state.items.get(key) + if (opened?.kind !== 'request') { return { dropped: 'request-unknown' } } - // Only while the journal holds it pending: a client's answer that landed first stands. - const cancelled = current && held && pendingPrompt(held) ? cancelledJournalPromptBody(held) : null return { - ...(input.execute - ? { - settle: - current && cancelled - ? [ - { - kind: 'item' as const, - identity: current.identity, - body: cancelled, - turnScope: current.scope - } - ] - : [] + settle: { + what: 'request-withdrawn', + resolve: (journal) => { + // The open ran first and named its row. Only while it is pending: a client's answer that + // landed first stands. + const held = opened.row ? journal.item(opened.row.itemId) : null + const cancelled = + held && pendingPrompt(held.body) ? cancelledJournalPromptBody(held.body) : null + if (!opened.row || !held || !cancelled) { + return [] } - : {}), - commit: (next) => next.obligations.delete(ref) + return [ + { + kind: 'item', + identity: opened.row.identity, + body: cancelled, + turnScope: held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + } + ] + } + }, + commit: (next) => next.items.delete(key) } } @@ -193,16 +207,25 @@ export function decideFrame( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { - const { state, journal, context } = input + const { state, context } = input const frame = unhandledProviderFrameJournalItem(context.agent, event.frameKind, event.payload) - if (!frame || !event.minted || !input.execute || !journal) { + if (!frame) { return {} } - const row = context.joins.place( - { family: 'frame', key: event.minted, thread: event.join?.thread ?? null }, + const row = context.rows.item( 'frame', - providerTimelinePlacement(context, state, event.join), - journal + context.rows.minted('f', input.serial()), + event.join?.thread ?? null ) - return { write: { identity: row.identity, body: frame.body, options: { turnScope: row.scope } } } + const write = { identity: row.identity, body: frame.body } + return { + writes: [ + { + reservedBytes: estimateStructuredAgentSessionItemBytes(row.identity, frame.body), + lifecycle: false, + options: { turnScope: providerTimelinePlacement(context, state, event.join) }, + resolve: () => write + } + ] + } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts index 44dec71dc576..f9e9e0e33e38 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts @@ -1,21 +1,16 @@ // One grammar event's journal writes, admitted as one sink transition. // -// Planning lays out the writes the event may need and changes nothing. What each write says, and -// which row it lands on, is decided by its resolver when the transition reaches its turn in the -// journal's write queue; those resolvers are the only code that changes the assembler's ledger. -// `onAdmitted` work (the forecast, text marked written) runs only when the sink takes the -// transition, so a refused event leaves the assembler exactly as it was and re-applying it is the -// retry. +// What each write says, and whether it writes at all, is decided by its resolver when the +// transition reaches its turn in the journal's write queue. `onAdmitted` work (the state, text +// marked written) runs only when the sink takes the transition, so a refused event leaves the +// assembler exactly as it was and re-applying it is the retry. import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' -import type { - StructuredAgentSessionTransitionJournal, - StructuredAgentSessionTransitionStep -} from '../agent-session-wire/structured-agent-session-transition' +import type { StructuredAgentSessionTransitionStep } from '../agent-session-wire/structured-agent-session-transition' /** The sink calls the assembler needs: one-operation transitions, and the journal as it stands. */ export type ProviderTimelineSink = Required< @@ -31,9 +26,7 @@ const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } export class ProviderTimelinePlan { private readonly steps: StructuredAgentSessionTransitionStep[] = [] private readonly admitted: (() => void)[] = [] - private readonly executions: ((journal: StructuredAgentSessionTransitionJournal) => void)[] = [] private lifecycle = false - private refusal: StructuredAgentSessionSinkAdmission | null = null item(step: Omit, lifecycle = false): void { this.steps.push({ kind: 'item', ...step }) @@ -50,38 +43,12 @@ export class ProviderTimelinePlan { this.admitted.push(change) } - /** Ledger work that rides the transition's place in the queue, after its last step resolved. */ - atExecution(change: (journal: StructuredAgentSessionTransitionJournal) => void): void { - this.executions.push(change) - } - - /** The event cannot be taken at all (over budget); nothing is submitted. */ - refuse(admission: StructuredAgentSessionSinkAdmission): void { - this.refusal ??= admission - } - - /** Whether submitting sends a transition (and so `landed` will be heard). */ - get writes(): boolean { - return this.steps.length > 0 || this.executions.length > 0 - } - - submit(sink: ProviderTimelineSink, landed?: () => void): StructuredAgentSessionSinkAdmission { - if (this.refusal) { - return this.refusal - } - if (this.executions.length > 0 && this.steps.length === 0) { - this.item({ - reservedBytes: 0, - resolve: () => null, - options: { turnScope: { kind: 'thread' } } - }) - } + submit(sink: ProviderTimelineSink): StructuredAgentSessionSinkAdmission { if (this.steps.length > 0) { const admission = sink.tryAppendTransition({ - steps: this.withExecutions(), + steps: this.steps, lifecycle: this.lifecycle, - publish: true, - ...(landed ? { landed: () => landed() } : {}) + publish: true }) if (!admission.accepted) { return admission @@ -92,35 +59,6 @@ export class ProviderTimelinePlan { } return ADMITTED } - - private withExecutions(): StructuredAgentSessionTransitionStep[] { - const last = this.steps.at(-1) - if (!last || this.executions.length === 0) { - return this.steps - } - const executions = this.executions - const finish = (journal: StructuredAgentSessionTransitionJournal) => - executions.forEach((change) => change(journal)) - const wrapped: StructuredAgentSessionTransitionStep = - last.kind === 'item' - ? { - ...last, - resolve: (journal) => { - const resolved = last.resolve(journal) - finish(journal) - return resolved - } - } - : { - ...last, - resolve: (journal) => { - const resolved = last.resolve(journal) - finish(journal) - return resolved - } - } - return [...this.steps.slice(0, -1), wrapped] - } } /** The sink as the assembler needs it; null for a sink without transitions or a journal view. */ diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts new file mode 100644 index 000000000000..72c892cfbe9f --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts @@ -0,0 +1,110 @@ +// Provider keys → journal rows, with no index to keep. +// +// Every row the assembler writes has an identity spelled from its key, so finding a row is +// spelling its id and reading that row: nothing to cache, evict or rebuild after a restart. The +// turn a row belongs to is the one the journal holds for it (its first write fixes it). Request +// incarnations are probed: the next is the first spelling the journal holds no row for. + +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { + ProviderTimelineIdentityScheme, + ProviderTimelineItemFamily, + ProviderTimelineKey +} from './provider-timeline-identity' + +export type ProviderTimelineRowId = { identity: AgentJournalItemIdentity; itemId: string } + +export type ProviderTimelineTurnRef = ProviderTimelineRowId & { + key: ProviderTimelineKey + /** The id the turn row carries and a client's Stop names. */ + turnId: string +} + +export type ProviderTimelineTurnRowState = 'absent' | 'running' | 'settled' + +type Journal = StructuredAgentSessionTransitionJournal + +/** Room an item step reserves for a request id: the longest incarnation suffix it could take. */ +const REQUEST_INCARNATION_BOUND = 1_000_000_000 + +export function providerKey(value: string): ProviderTimelineKey { + return { source: 'provider', value } +} + +export function turnOf(scope: AgentJournalTurnScope): string | null { + return scope.kind === 'turn' ? scope.turnItemId : null +} + +/** What the journal holds for a turn row: any writer's settlement (a person's Stop included). */ +export function providerTimelineTurnRowState( + journal: Journal, + turnItemId: string +): ProviderTimelineTurnRowState { + const row = readAgentJournalTurn(journal.itemBody(turnItemId) ?? undefined) + return !row ? 'absent' : row.state === 'running' ? 'running' : 'settled' +} + +export class ProviderTimelineRows { + constructor( + private readonly deps: { + scheme: ProviderTimelineIdentityScheme + generation: string + namespace: string + } + ) {} + + /** A key unique to this acquisition; `serial` is taken from the state when the event is admitted. */ + minted(kind: string, serial: number): ProviderTimelineKey { + return { source: 'minted', value: `${this.deps.generation}:${kind}${serial}` } + } + + turn(key: ProviderTimelineKey): ProviderTimelineTurnRef { + const address = { namespace: this.deps.namespace, key } + const identity = this.deps.scheme.turn(address) + return { + key, + identity, + itemId: agentJournalItemKey(identity), + turnId: this.deps.scheme.turnId(address) + } + } + + item( + family: ProviderTimelineItemFamily, + key: ProviderTimelineKey, + thread: string | null + ): ProviderTimelineRowId { + return this.row(this.deps.scheme.item({ namespace: this.deps.namespace, family, key, thread })) + } + + /** The widest id a request under `key` could take, for the room its write reserves. */ + widestRequest(key: string): ProviderTimelineRowId { + return this.request(key, REQUEST_INCARNATION_BOUND) + } + + /** The first incarnation under `key` the journal holds no row for: never one already written. */ + nextRequest(key: string, journal: Journal): ProviderTimelineRowId { + for (let incarnation = 1; ; incarnation += 1) { + const row = this.request(key, incarnation) + if (journal.itemBody(row.itemId) === null) { + return row + } + } + } + + private request(key: string, incarnation: number): ProviderTimelineRowId { + return this.row( + this.deps.scheme.request({ generation: this.deps.generation, key, incarnation }) + ) + } + + private row(identity: AgentJournalItemIdentity): ProviderTimelineRowId { + return { identity, itemId: agentJournalItemKey(identity) } + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts index dd4e92166551..b8fb5f532233 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -1,10 +1,10 @@ // What a turn's end or the session's end settles, read from the journal when the settlement runs. // -// The same mechanism the dead-generation settlement uses after a restart: every row still waiting -// on the row that settles it — a running tool call, a pending prompt — is settled from its body as -// the journal holds it then. Nothing about open work is trusted from memory, so a prompt a client -// answered a moment earlier stays answered, a row a previous assembler opened is settled too, and -// a second settlement finds nothing left to do. +// The same mechanism, and the same terminal bodies, as the dead-generation settlement after a +// restart: every row still waiting on the row that settles it — a running tool call, a pending +// prompt — is settled from its body as the journal holds it then. Nothing about open work is +// trusted from memory, so a prompt a client answered a moment earlier stays answered, and a +// second settlement finds nothing left to do. import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { @@ -17,8 +17,8 @@ import { agentJournalTurnBody, readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import { terminalAgentJournalBody } from '../agent-session-journal/journal-terminal-settlement' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { lostProviderTimelineBackgroundTasks } from './provider-timeline-background-tasks' import { @@ -36,19 +36,6 @@ export type ProviderTimelineTurnEnd = } | { state: 'unverifiable' } -/** What a row still open when its turn or the session ends becomes; null when it already stands. */ -export function settledProviderTimelineBody( - body: AgentJournalItemBody -): AgentJournalItemBody | null { - if (body.kind === 'tool-call') { - return body.state === 'running' ? { ...body, state: 'failed' } : null - } - if (body.kind === 'approval' || body.kind === 'question') { - return body.resolution.state === 'pending' ? cancelledJournalPromptBody(body) : null - } - return null -} - /** The end owns the turn's terminal fields; `unverifiable` carries no end and no verdict. Fields * keep the order every lane writes them in, so the row is the same bytes whoever ends it. */ export function endedProviderTimelineTurn( @@ -93,7 +80,7 @@ export function providerTimelineSettlement( // Background tasks outlive turns; only the session's end leaves them past seeing. const settled = !covered ? null - : (settledProviderTimelineBody(body) ?? + : (terminalAgentJournalBody(body) ?? (scope === 'session' ? lostProviderTimelineBackgroundTasks(body) : null)) const identity = settled ? parseAgentJournalItemKey(itemId) : null if (settled && identity) { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts index a74fd943bcdd..d236f2536fab 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -1,28 +1,25 @@ -// What the assembler knows about the session, held twice. +// What the assembler knows about the session: one state, in admission order. // -// The ledger is the truth the decisions run on: it changes only inside transition resolvers, at -// each event's turn in the journal's write queue, and everything in it is either a cache of the -// journal (re-derived from the fold when missing) or this run's own memory (items it closed, the -// sends waiting for a turn). The forecast is a copy plus what admitted events still in the queue -// are expected to change; it only answers `apply` before those events land, and is rebuilt from -// the ledger as they do. - -import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +// An event changes it only when the sink admits the event, so a refused event changed nothing and +// re-applying it is the retry. For this producer's own rows admission order is write order (the +// sink is a FIFO queue), so the state never has to be re-read from the journal. Facts other +// writers own (a person's Stop, a client's answer) are read from the journal by key, never copied. + import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalTurnLifecycle, type AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { BoundedMap } from '../../../shared/bounded-map' +import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import type { ProviderTimelineTurnRef } from './provider-timeline-joins' +import { + providerTimelineTurnRowState, + type ProviderTimelineRowId, + type ProviderTimelineTurnRef +} from './provider-timeline-rows' -/** Closed items remembered so a repeat close is dropped and an open reopens; past this, the - * journal decides from the row alone. */ +/** Closed items remembered so a repeat close is dropped; past this the journal decides alone. */ const MAX_CLOSED_ITEMS = 512 -/** Turns remembered as settled without asking the journal. */ -const MAX_SETTLED_TURNS = 256 /** Sends waiting for a turn; a provider that never opens one cannot grow this without bound. */ const MAX_PENDING_INPUTS = 64 const MAX_PENDING_INPUT_BYTES = 64 * 1024 @@ -35,101 +32,77 @@ export type ProviderTimelineOpenTurn = ProviderTimelineTurnRef & { running: AgentJournalTurnLifecycle } +/** A user message waiting for the turn it opens: Orca's send, or the provider's saved one. */ export type ProviderTimelinePendingInput = { - clientMessageId: string - requestedAt: number - /** The turn row the provider said the send opens; absent: the next turn to open. */ + userItemId: string + requestedAt?: number + /** The turn row the provider said it opens; absent: the next turn to open. */ turnItemId?: string } -/** Work still waiting on a row that settles it: a running tool or a pending request. Keyed by - * the item's join reference (a request's by its key, whatever its incarnation). */ -export type ProviderTimelineObligation = { - /** The row, once a resolver placed it. */ - itemId: string | null +/** Work this acquisition holds open (a running tool, a pending request), or an item it closed. */ +export type ProviderTimelineOpenItem = { + kind: 'item' | 'request' + /** The row; a request's is chosen by its write, which records it here. */ + row: ProviderTimelineRowId | null /** The turn whose end settles it; null for a row in no turn. */ turnItemId: string | null bytes: number + /** Closed here: holds no budget; a repeat close is dropped and an open reopens it. */ + closed: boolean } -export type ProviderTimelineTurnStatus = 'absent' | 'running' | 'settled' - export class ProviderTimelineState { ended = false open: ProviderTimelineOpenTurn | null = null + /** The turn that ended last, for context facts that arrive after it. */ latest: ProviderTimelineTurnRef | null = null inputs: ProviderTimelinePendingInput[] = [] - /** Items this run closed, with the turn they closed in. */ - closed = new BoundedMap({ - maxEntries: MAX_CLOSED_ITEMS, - maxBytes: MAX_CLOSED_ITEMS * 1024, - sizeOf: (turnItemId, ref) => bytes(ref) + bytes(turnItemId ?? '') + 16 - }) - settled = new BoundedMap({ - maxEntries: MAX_SETTLED_TURNS, - maxBytes: MAX_SETTLED_TURNS * 1024, - sizeOf: (_settled, turnItemId) => bytes(turnItemId) + 8 - }) - obligations = new Map() - /** Whether the open turn was taken from the journal yet (ledger only). */ - hydrated = false - - constructor(public namespace: string) {} + /** Keyed by row id (an item) or `request:` (a request, whatever its incarnation). */ + items = new Map() + /** Minted keys and settlement ids; taken only by admitted events. */ + serial = 0 get scope(): AgentJournalTurnScope { return this.open ? { kind: 'turn', turnItemId: this.open.itemId } : AGENT_JOURNAL_THREAD_SCOPE } - clone(): ProviderTimelineState { - const copy = new ProviderTimelineState(this.namespace) - copy.ended = this.ended - copy.open = this.open && { ...this.open } - copy.latest = this.latest - copy.inputs = [...this.inputs] - for (const [key, value] of this.closed.entries()) { - copy.closed.set(key, value) - } - for (const [key, value] of this.settled.entries()) { - copy.settled.set(key, value) + /** Serials for one event: committed with it, so a refused event takes none. */ + serials(): { next(): number; commit(): void } { + let taken = this.serial + return { + next: () => (taken += 1), + commit: () => { + this.serial = taken + } } - copy.obligations = new Map(this.obligations) - copy.hydrated = this.hydrated - return copy } - /** Settled once anything settled it: this run, or any writer of the journal (a person's Stop). - * Memory says only what the journal cannot yet: a turn opened by a write still queued. */ - status( - turn: { itemId: string }, - journal: StructuredAgentSessionTransitionJournal | null - ): ProviderTimelineTurnStatus { - if (this.settled.has(turn.itemId)) { - return 'settled' - } - const row = readAgentJournalTurn(journal?.itemBody(turn.itemId) ?? undefined) - if (row) { - return row.state === 'running' ? 'running' : 'settled' + /** Ends the open turn when another writer of the journal settled its row. */ + reconcile(journal: StructuredAgentSessionTransitionJournal): void { + if (this.open && providerTimelineTurnRowState(journal, this.open.itemId) === 'settled') { + this.endTurn(this.open) } - return this.open?.itemId === turn.itemId ? 'running' : 'absent' } - /** Ends the open turn when the journal holds it settled: another writer ended it. */ - reconcile(journal: StructuredAgentSessionTransitionJournal): void { - if (!this.hydrated) { - this.hydrate(journal) - } - const row = this.open && readAgentJournalTurn(journal.itemBody(this.open.itemId) ?? undefined) - if (this.open && row && row.state !== 'running') { - this.endTurn(this.open) + /** Whether the journal shows the work under `key` settled by any writer (a client's answer). + * Work whose write is still queued stays open. */ + settledInJournal(key: string, journal: StructuredAgentSessionTransitionJournal): boolean { + const entry = this.items.get(key) + if (!entry || entry.closed || !entry.row) { + return false } + const body = journal.itemBody(entry.row.itemId) + // A request write that found its turn over left no row: nothing is pending. + return body === null ? entry.kind === 'request' : !requiresTerminalSettlement(body) } - /** A send waiting for its turn; past the bounds the oldest is forgotten (its turn opens as the - * provider's own). */ + /** A user message waiting for its turn; past the bounds the oldest is forgotten (its turn opens + * as the provider's own). */ wait(pending: ProviderTimelinePendingInput): void { this.inputs.push(pending) const size = (input: ProviderTimelinePendingInput) => - bytes(input.clientMessageId) + bytes(input.turnItemId ?? '') + 16 + bytes(input.userItemId) + bytes(input.turnItemId ?? '') + 16 let held = this.inputs.reduce((total, input) => total + size(input), 0) while (this.inputs.length > MAX_PENDING_INPUTS || held > MAX_PENDING_INPUT_BYTES) { const forgotten = this.inputs.shift() @@ -140,7 +113,7 @@ export class ProviderTimelineState { } } - /** The send that opens `turnItemId`: the one that named it, else the oldest that named none. */ + /** The message that opens `turnItemId`: the one that named it, else the oldest that named none. */ opener(turnItemId: string): ProviderTimelinePendingInput | undefined { return ( this.inputs.find((input) => input.turnItemId === turnItemId) ?? @@ -148,69 +121,32 @@ export class ProviderTimelineState { ) } - /** The turn is over, and so is everything its settlement covered. */ + /** Closed here, in the turn it joined; past the bound the oldest closed items are forgotten. */ + close(key: string, turnItemId: string | null): void { + this.items.delete(key) + this.items.set(key, { kind: 'item', row: null, turnItemId, bytes: 0, closed: true }) + const closed = [...this.items].filter(([, entry]) => entry.closed) + for (const [oldest] of closed.slice(0, Math.max(0, closed.length - MAX_CLOSED_ITEMS))) { + this.items.delete(oldest) + } + } + + /** The turn is over, and so is everything in it: its end settled that work. */ endTurn(turn: ProviderTimelineTurnRef): void { if (this.open?.itemId === turn.itemId) { this.open = null } this.latest = turn - this.settled.set(turn.itemId, true) - for (const [key, obligation] of this.obligations) { - if (obligation.turnItemId === turn.itemId) { - this.obligations.delete(key) - } - } - // A settled turn's rows are judged by the turn now. - for (const [key, turnItemId] of this.closed.entries()) { - if (turnItemId === turn.itemId) { - this.closed.delete(key) + for (const [key, entry] of this.items) { + if (entry.turnItemId === turn.itemId) { + this.items.delete(key) } } } - /** The session is over: nothing it held is open any more. */ endSession(): void { + this.ended = true this.open = null - this.obligations.clear() - this.closed.clear() - } - - reset(namespace: string): void { - this.namespace = namespace - this.ended = false - this.open = null - this.latest = null - this.inputs = [] - this.obligations.clear() - this.closed.clear() - this.settled.clear() - } - - /** Takes the open turn from the journal: the newest turn row it holds running. Its key is the - * turn id the row carries, which is the provider's own turn id for a scheme that spells it. */ - hydrate(journal: StructuredAgentSessionTransitionJournal): void { - this.hydrated = true - if (this.open) { - return - } - let newest = -1 - journal.visitItems((itemId, sequence, body) => { - const running = readAgentJournalTurn(body) - const identity = running?.state === 'running' ? parseAgentJournalItemKey(itemId) : null - if (!running || !identity || sequence < newest) { - return - } - newest = sequence - this.open = { - address: { - namespace: this.namespace, - key: { source: 'provider', value: running.turnId } - }, - identity, - itemId, - turnId: running.turnId, - running - } - }) + this.items.clear() } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts deleted file mode 100644 index b362062b25ca..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts +++ /dev/null @@ -1,185 +0,0 @@ -// The ledger of which row each text stream writes, changed only at its writes' turn in the queue. -// -// A stream's row is decided by its first write, like every other row: a provider-named message -// the journal already holds is one this run resumes (its turn still running: the stream adopts the -// row's text as its prefix) or a replay (its turn settled, or this run closed it: nothing is -// written). An anonymous stream continues the row of the one before it when the event that ended -// that one turned out a replay. Every later write checks again that the row is not over, against -// the journal, so a turn another writer ended stops the stream there too. - -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' -import { BoundedMap } from '../../../shared/bounded-map' -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import { - providerTimelineLedger, - providerTimelinePlacement, - type ProviderTimelineContext -} from './provider-timeline-context' -import { turnOf } from './provider-timeline-decision' -import type { ProviderTimelineRow } from './provider-timeline-joins' -import type { ProviderTimelineState } from './provider-timeline-state' -import type { ProviderTimelineStream } from './provider-timeline-text-streams' - -/** The row a stream writes, and the text it held before this run streamed into it. */ -export type ProviderTimelineStreamRow = { row: ProviderTimelineRow; prefix: string } - -type Owned = ProviderTimelineStreamRow | 'replayed' - -/** Rows kept for the anonymous stream that may continue them, and streams stopped mid-flight - * whose queued writes must still write nothing. */ -const MAX_CARRIED_ROWS = 64 -const MAX_STOPPED_STREAMS = 512 - -function providerTimelineMessageText(body: AgentJournalItemBody | null): string { - return body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' -} - -export class ProviderTimelineStreamRows { - /** The streams planning still holds open. */ - private readonly owned = new Map() - private readonly carried = new BoundedMap({ - maxEntries: MAX_CARRIED_ROWS - }) - private readonly stopped = new BoundedMap({ maxEntries: MAX_STOPPED_STREAMS }) - - constructor( - private readonly context: ProviderTimelineContext, - /** Drops a stream planning still holds open, so its next delta starts another. */ - private readonly forget: (stream: ProviderTimelineStream) => void - ) {} - - /** At a write: the row it lands on, or null when it writes nothing. */ - resolve( - stream: ProviderTimelineStream, - journal: StructuredAgentSessionTransitionJournal - ): ProviderTimelineStreamRow | null { - if (this.stopped.has(stream.id)) { - return null - } - const ledger = providerTimelineLedger(this.context, journal) - let owned = this.owned.get(stream.id) - if (!owned) { - owned = this.own(stream, ledger, journal) - this.owned.set(stream.id, owned) - } - if (owned === 'replayed' || ledger.ended) { - return null - } - // Every write, not only the first: whoever ended the row's turn since, this run or another - // writer of the journal, the stream never writes into it again. - if (this.over(stream, owned.row, ledger, journal)) { - this.stop(stream) - return null - } - return owned - } - - /** The event that released `stream` ran: `held` when it really ended the stream's message. */ - release(stream: ProviderTimelineStream, held: boolean): void { - stream.boundary = held ? 'held' : 'void' - const owned = this.owned.get(stream.id) - this.owned.delete(stream.id) - // A named stream's next delta finds its row again through the journal; an anonymous one's row - // is kept for the stream that follows it. - if (!held && !stream.named && owned && owned !== 'replayed') { - this.carried.set(stream.id, owned) - } - } - - /** Stops every stream whose row is over now. */ - retire( - streams: readonly ProviderTimelineStream[], - journal: StructuredAgentSessionTransitionJournal - ): void { - const ledger = providerTimelineLedger(this.context, journal) - for (const stream of streams) { - const owned = this.owned.get(stream.id) - if (owned && owned !== 'replayed' && this.over(stream, owned.row, ledger, journal)) { - this.stop(stream) - } - } - } - - /** The turn the stream's row is in, once a write placed it; else the one planning expects. */ - turn(stream: ProviderTimelineStream): string | null { - const owned = this.owned.get(stream.id) - return owned && owned !== 'replayed' ? turnOf(owned.row.scope) : stream.turnItemId - } - - clear(): void { - this.owned.clear() - this.carried.clear() - this.stopped.clear() - } - - private own( - stream: ProviderTimelineStream, - ledger: ProviderTimelineState, - journal: StructuredAgentSessionTransitionJournal - ): Owned { - const { joins } = this.context - if (stream.named && ledger.closed.has(stream.key)) { - return 'replayed' - } - const carried = this.continued(stream, journal) - if (carried) { - return carried - } - const found = stream.named ? joins.find(stream.join, journal) : null - if (found) { - const turnItemId = turnOf(found.scope) - if (turnItemId !== null && ledger.status({ itemId: turnItemId }, journal) === 'settled') { - return 'replayed' - } - return { row: found, prefix: providerTimelineMessageText(journal.itemBody(found.itemId)) } - } - const placement = providerTimelinePlacement(this.context, ledger, { - ...(stream.join.thread === null ? {} : { thread: stream.join.thread }), - ...(stream.turn === undefined ? {} : { turn: stream.turn }) - }) - const itemClass = stream.channel === 'assistant' ? 'message' : 'reasoning' - return { row: joins.place(stream.join, itemClass, placement, journal), prefix: '' } - } - - /** The row of the message this one continues: an earlier anonymous stream whose release the - * journal did not take (its event was a replay). */ - private continued( - stream: ProviderTimelineStream, - journal: StructuredAgentSessionTransitionJournal - ): Owned | null { - let previous = stream.follows - delete stream.follows - for (; previous?.boundary === 'void'; previous = previous.follows) { - const kept = this.carried.get(previous.id) - if (kept) { - this.carried.delete(previous.id) - return { - row: kept.row, - prefix: providerTimelineMessageText(journal.itemBody(kept.row.itemId)) - } - } - } - return null - } - - /** Whether the row is past streaming into: its item closed, or its turn settled. */ - private over( - stream: ProviderTimelineStream, - row: ProviderTimelineRow, - ledger: ProviderTimelineState, - journal: StructuredAgentSessionTransitionJournal - ): boolean { - const turn = turnOf(row.scope) - return ( - (stream.named && ledger.closed.has(stream.key)) || - (turn !== null && ledger.status({ itemId: turn }, journal) === 'settled') - ) - } - - /** The stream writes nothing more; a later delta under its key starts another. */ - private stop(stream: ProviderTimelineStream): void { - this.owned.delete(stream.id) - this.stopped.set(stream.id, true) - this.forget(stream) - } -} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts index 8b793ef2139f..0cf3452440bb 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts @@ -1,30 +1,45 @@ -// Text events: planning-side streams, admitted like every other event. +// Text events: streams admitted like every other event. -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' -import type { - ProviderTimelineApplyHost, - ProviderTimelineApplyResult, - ProviderTimelineInFlight -} from './provider-timeline-apply-host' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { ProviderTimelineHold } from './provider-timeline-budget' import { PROVIDER_TIMELINE_OVER_BUDGET } from './provider-timeline-budget' +import type { ProviderTimelineDropRule } from './provider-timeline-decision' import type { ProviderTimelineEvent } from './provider-timeline-event' -import { ProviderTimelinePlan } from './provider-timeline-plan' +import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' +import { providerTimelineTurnRowState, turnOf } from './provider-timeline-rows' +import type { ProviderTimelineState } from './provider-timeline-state' +import type { ProviderTimelineTextStreams } from './provider-timeline-text-streams' + +export type ProviderTimelineApplyResult = { + /** The sink's answer for the event's writes; refused means nothing changed. */ + admission: StructuredAgentSessionSinkAdmission + dropped?: ProviderTimelineDropRule +} + +/** What the text appliers share with the assembler that owns them. */ +export type ProviderTimelineTextHost = { + sink: ProviderTimelineSink + state: ProviderTimelineState + streams: ProviderTimelineTextStreams + journal: StructuredAgentSessionTransitionJournal | null + admits(hold: ProviderTimelineHold): boolean +} const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } export function applyProviderTimelineTextDelta( - host: ProviderTimelineApplyHost, + host: ProviderTimelineTextHost, event: Extract ): ProviderTimelineApplyResult { - const { streams } = host - const forecast = host.forecast() - if (forecast.ended) { + const { streams, state } = host + if (state.ended) { return { admission: ADMITTED, dropped: 'session-ended' } } - const journal = host.journal() const plan = new ProviderTimelinePlan() - let stream = streams.get(streams.key(event.item, event.join, forecast)) + const key = streams.key(event.item, event.join) + let stream = streams.get(key) if (stream && !streams.continues(stream, event.channel, event.producer)) { if (stream.named) { return { admission: ADMITTED, dropped: 'stream-mismatch' } @@ -36,70 +51,59 @@ export function applyProviderTimelineTextDelta( stream = undefined } if (!stream) { - if ('id' in event.item && settledNamedItem(host, event, journal)) { + if ('id' in event.item && settledNamedItem(host, key)) { return { admission: ADMITTED, dropped: 'item-settled' } } + const serials = state.serials() stream = streams.start({ item: event.item, join: event.join, channel: event.channel, producer: event.producer, - state: forecast + state, + serial: serials.next() }) if (!host.admits({ key: stream.key, bytes: stream.bytes })) { return { admission: PROVIDER_TIMELINE_OVER_BUDGET } } + plan.onAdmitted(serials.commit) } streams.planAppend(plan, stream, event.text) - return { admission: host.submit(plan, { executed: false, commit: undefined }) } + return { admission: plan.submit(host.sink) } } /** A named delta for an item this run closed, or one the journal holds in a settled turn. */ -function settledNamedItem( - host: ProviderTimelineApplyHost, - event: Extract, - journal: StructuredAgentSessionTransitionJournal | null -): boolean { - const { context } = host - const forecast = host.forecast() - if (!('id' in event.item)) { - return false - } - const join = { - family: 'item' as const, - key: { source: 'provider' as const, value: event.item.id }, - thread: event.join?.thread ?? null - } - if (forecast.closed.has(context.joins.reference(join, forecast.namespace))) { +function settledNamedItem(host: ProviderTimelineTextHost, key: string): boolean { + if (host.state.items.get(key)?.closed) { return true } - const row = context.joins.find(join, journal, forecast.namespace) - const turnItemId = row?.scope.kind === 'turn' ? row.scope.turnItemId : null - return turnItemId !== null && forecast.status({ itemId: turnItemId }, journal) === 'settled' + const held = host.journal?.item(key) + const turn = held ? turnOf(held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE) : null + return ( + host.journal !== null && + turn !== null && + providerTimelineTurnRowState(host.journal, turn) === 'settled' + ) } export function applyProviderTimelineTextClose( - host: ProviderTimelineApplyHost, + host: ProviderTimelineTextHost, event: Extract ): ProviderTimelineApplyResult { - const { streams } = host - const forecast = host.forecast() - if (forecast.ended) { + const { streams, state } = host + if (state.ended) { return { admission: ADMITTED, dropped: 'session-ended' } } - const stream = streams.get(streams.key(event.item, event.join, forecast)) + const stream = streams.get(streams.key(event.item, event.join)) if (!stream) { return { admission: ADMITTED, dropped: 'stream-unknown' } } const plan = new ProviderTimelinePlan() streams.planFlush(plan, stream) streams.planClose(plan, stream, event.text) - const entry: ProviderTimelineInFlight = { - executed: false, - commit: stream.named ? (state) => state.closed.set(stream.key, stream.turnItemId) : undefined + if (stream.named) { + // The close settles the item for full snapshots too. + plan.onAdmitted(() => state.close(stream.key, turnOf(stream.scope))) } - plan.atExecution(() => { - entry.executed = true - }) - return { admission: host.submit(plan, entry) } + return { admission: plan.submit(host.sink) } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts index 1557873426b6..da869954907d 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts @@ -6,11 +6,14 @@ // message. Deltas accumulate in the shared coalescer, and a row is a snapshot of the text so far. // Text owed to the journal is written inside the next event's transition — every other event is // an ordering barrier — or by the coalescer's window, and is marked written once the sink takes it. -// Which row each write lands on is the journal's call, at the write (`provider-timeline-stream-rows.ts`). +// Every write first reads the turn its row is in: once any writer settled that turn (a person's +// Stop, the turn's own end), the stream writes nothing more. -import type { - AgentJournalItemBody, - AgentJournalProducerLinkage +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalProducerLinkage, + type AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import { boundInlineText, @@ -21,77 +24,60 @@ import { type AgentSessionDeltaCoalescerDeps } from '../agent-session-wire/agent-session-delta-coalescer' import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' -import type { - StructuredAgentSessionItemAppendOptions, - StructuredAgentSessionSinkAdmission -} from '../agent-session-wire/structured-agent-session-event-sink' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { providerTimelineEntryBytes, providerTimelinePlacement, type ProviderTimelineContext } from './provider-timeline-context' +import type { ProviderTimelineResolvedWrite } from './provider-timeline-decision' import type { ProviderTimelineJoin, ProviderTimelineTextChannel, ProviderTimelineTextItem } from './provider-timeline-event' -import { BoundedMap } from '../../../shared/bounded-map' import { providerTimelineKeyPart } from './provider-timeline-identity' -import type { ProviderTimelineItemJoin, ProviderTimelineRow } from './provider-timeline-joins' import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' -import { ProviderTimelineStreamRows } from './provider-timeline-stream-rows' +import { + providerKey, + providerTimelineTurnRowState, + turnOf, + type ProviderTimelineRowId +} from './provider-timeline-rows' import type { ProviderTimelineState } from './provider-timeline-state' -/** One open text stream, as planning knows it. */ +/** One open text stream. */ export type ProviderTimelineStream = { - /** Unique per stream: the coalescer's key, and the ledger's for the row it owns. */ + /** The coalescer's key, unique per stream. */ id: string - /** The stream's slot: a named stream's is its item's join reference, which the budget shares. */ + /** The stream's slot: a named stream's is its item's row id, which the budget shares. */ key: string - join: ProviderTimelineItemJoin - /** The turn the provider named, if any. */ - turn: string | undefined - /** The turn planning expects its row in, so a turn's end releases it. */ - turnItemId: string | null + row: ProviderTimelineRowId + /** The turn its row joins when it is new; an existing row keeps its own. */ + scope: AgentJournalTurnScope named: boolean channel: ProviderTimelineTextChannel producer: AgentJournalProducerLinkage | undefined /** Whether any text was written; a whitespace-only stream completes nothing. */ written: boolean bytes: number - /** The anonymous stream an event released just before this one began: this one continues its - * row unless that event's boundary landed. */ - follows?: ProviderTimelineStream - /** Execution: whether the event that released this stream ended its message. */ - boundary?: 'held' | 'void' -} - -type Resolved = { - identity: ProviderTimelineRow['identity'] - body: AgentJournalItemBody - options: StructuredAgentSessionItemAppendOptions + /** A write found its row's turn settled: it writes nothing more. */ + stopped: boolean } export class ProviderTimelineTextStreams { private readonly streams = new Map() private readonly byId = new Map() - private readonly rows: ProviderTimelineStreamRows - /** Per anonymous slot, the stream a boundary not yet decided released last. */ - private readonly released = new BoundedMap({ maxEntries: 64 }) private readonly coalescer - private serial = 0 constructor( private readonly deps: { sink: ProviderTimelineSink context: ProviderTimelineContext - generation: string coalesceMs?: number schedule?: AgentSessionDeltaCoalescerDeps['schedule'] } ) { - this.rows = new ProviderTimelineStreamRows(deps.context, (stream) => this.forget(stream)) this.coalescer = createAgentSessionDeltaCoalescer({ ...(deps.coalesceMs === undefined ? {} : { windowMs: deps.coalesceMs }), ...(deps.schedule ? { schedule: deps.schedule } : {}), @@ -103,47 +89,45 @@ export class ProviderTimelineTextStreams { /** What the budget counts: one entry per open stream. */ get open(): readonly ProviderTimelineStream[] { - return [...this.streams.values()] + return [...this.streams.values()].filter((stream) => !stream.stopped) } /** The stream slot `item` names on its thread. */ - key( - item: ProviderTimelineTextItem, - join: ProviderTimelineJoin | undefined, - state: ProviderTimelineState - ): string { + key(item: ProviderTimelineTextItem, join: ProviderTimelineJoin | undefined): string { + const thread = join?.thread ?? null return 'id' in item - ? this.deps.context.joins.reference(this.itemJoin(item, join), state.namespace) - : `stream:${providerTimelineKeyPart(join?.thread ?? '')}:${providerTimelineKeyPart(item.stream)}` + ? this.deps.context.rows.item('item', providerKey(item.id), thread).itemId + : `stream:${providerTimelineKeyPart(thread ?? '')}:${providerTimelineKeyPart(item.stream)}` } + /** The open stream in `key`; a stopped one is let go, so the next delta starts another. */ get(key: string): ProviderTimelineStream | undefined { - return this.streams.get(key) + const stream = this.streams.get(key) + if (stream?.stopped) { + this.forget(stream) + return undefined + } + return stream } - /** A new stream; the caller has checked the budget for its `bytes`. */ + /** A new stream, not yet open; `serial` names it (and an anonymous one's row). */ start(input: { item: ProviderTimelineTextItem join: ProviderTimelineJoin | undefined channel: ProviderTimelineTextChannel producer: AgentJournalProducerLinkage | undefined state: ProviderTimelineState + serial: number }): ProviderTimelineStream { - this.serial += 1 + const { rows } = this.deps.context + const thread = input.join?.thread ?? null const named = 'id' in input.item - const join = - 'id' in input.item ? this.itemJoin(input.item, input.join) : this.anonymousJoin(input.join) - const placement = providerTimelinePlacement(this.deps.context, input.state, input.join) - const key = this.key(input.item, input.join, input.state) - const follows = named ? undefined : this.released.get(key) - this.released.delete(key) + const key = 'id' in input.item ? providerKey(input.item.id) : rows.minted('s', input.serial) return { - id: `${this.deps.generation}:s${this.serial}`, - key, - ...(follows ? { follows } : {}), - join, - turn: input.join?.turn, - turnItemId: placement.scope.kind === 'turn' ? placement.scope.turnItemId : null, + id: `s${input.serial}`, + key: this.key(input.item, input.join), + row: rows.item('item', key, thread), + scope: providerTimelinePlacement(this.deps.context, input.state, input.join), named, channel: input.channel, producer: input.producer, @@ -152,7 +136,8 @@ export class ProviderTimelineTextStreams { key: 'id' in input.item ? input.item.id : input.item.stream, join: input.join, producer: input.producer - }) + }), + stopped: false } } @@ -186,43 +171,18 @@ export class ProviderTimelineTextStreams { } } - /** Ends the streams `which` selects; their text was flushed by the same event. Planning starts - * the next message at once; `landed`, asked at execution, says whether the event really ended - * them: when it did not (a replay the journal held), the next anonymous message continues - * this one's row, and a named stream's next delta resumes its row from the journal. */ + /** Ends the streams `which` selects once the event is admitted; their text was flushed by it. */ planRelease( plan: ProviderTimelinePlan, - which: (stream: ProviderTimelineStream) => boolean, - landed?: () => boolean + which: (stream: ProviderTimelineStream) => boolean ): void { const released = [...this.streams.values()].filter(which) - if (released.length === 0) { - return + if (released.length > 0) { + plan.onAdmitted(() => released.forEach((stream) => this.forget(stream))) } - plan.onAdmitted(() => - released.forEach((stream) => { - this.forget(stream) - if (landed && !stream.named) { - this.released.set(stream.key, stream) - } - }) - ) - plan.atExecution(() => - released.forEach((stream) => this.rows.release(stream, !landed || landed())) - ) - } - - /** At a turn's or the session's end: every stream whose row is over now stops. */ - planRetire(plan: ProviderTimelinePlan): void { - plan.atExecution((journal) => this.rows.retire(this.open, journal)) - } - - /** The turn a stream's row is in, once a write placed it; else the one planning expects. */ - rowTurn(stream: ProviderTimelineStream): string | null { - return this.rows.turn(stream) } - /** Ends one stream with the provider's final text, else what streamed; it settles the item. */ + /** Ends one stream with the provider's final text, else what streamed. */ planClose(plan: ProviderTimelinePlan, stream: ProviderTimelineStream, finalText?: string): void { if (finalText === undefined) { const owed = this.coalescer.dirty().find(({ key }) => key === stream.id) @@ -230,18 +190,8 @@ export class ProviderTimelineTextStreams { this.planText(plan, stream, owed.snapshot.text) } } else { - this.planText( - plan, - stream, - boundInlineText(finalText, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text, - true - ) - } - if (stream.named) { - // The close settles the item for full snapshots too, in the turn its row is in. - plan.atExecution(() => - this.deps.context.ledger.closed.set(stream.key, this.rows.turn(stream)) - ) + const text = boundInlineText(finalText, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + this.planText(plan, stream, text, true) } this.planRelease(plan, (each) => each === stream) } @@ -252,8 +202,6 @@ export class ProviderTimelineTextStreams { dispose(): void { this.open.forEach((stream) => this.forget(stream)) - this.rows.clear() - this.released.clear() this.coalescer.dispose() } @@ -265,7 +213,7 @@ export class ProviderTimelineTextStreams { } const plan = new ProviderTimelinePlan() this.planText(plan, stream, text) - const admission: StructuredAgentSessionSinkAdmission = plan.submit(this.deps.sink) + const admission = plan.submit(this.deps.sink) // A failed or closed sink can never take it; the session's end owns what it leaves. return admission.accepted || admission.reason !== 'backpressure' } @@ -281,16 +229,11 @@ export class ProviderTimelineTextStreams { plan.onAdmitted(() => this.coalescer.markFlushed(stream.id)) return } - const placeholder = this.message(stream, text) + const body = this.message(stream, text) plan.item({ - // Room for the text and the longest identity a scheme spells; a resumed prefix only paces. - reservedBytes: estimateStructuredAgentSessionItemBytes( - { provider: 'orca', clientMessageId: 'x'.repeat(1024) }, - placeholder - ), - paced: true, - resolve: (journal) => this.resolveText(stream, text, providerFinal, journal), - options: { ...stream.producer, turnScope: { kind: 'thread' } } + reservedBytes: estimateStructuredAgentSessionItemBytes(stream.row.identity, body), + resolve: (journal) => this.resolveText(stream, body, journal), + options: { ...stream.producer, turnScope: stream.scope } }) plan.onAdmitted(() => { stream.written = true @@ -298,27 +241,23 @@ export class ProviderTimelineTextStreams { }) } - /** At the write's turn in the queue: the row the stream owns, and its text so far. */ + /** Every write, not only the first: whoever ended the row's turn since, this run or another + * writer of the journal, the stream never writes into it again. */ private resolveText( stream: ProviderTimelineStream, - text: string, - providerFinal: boolean, + body: AgentJournalItemBody, journal: StructuredAgentSessionTransitionJournal - ): Resolved | null { - const owned = this.rows.resolve(stream, journal) - if (!owned) { + ): ProviderTimelineResolvedWrite | null { + const held = journal.item(stream.row.itemId) + const turn = turnOf(held ? (held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE) : stream.scope) + if ( + stream.stopped || + (turn !== null && providerTimelineTurnRowState(journal, turn) === 'settled') + ) { + stream.stopped = true return null } - const { row, prefix } = owned - return { - identity: row.identity, - body: this.message(stream, providerFinal ? text : `${prefix}${text}`), - options: { - ...stream.producer, - turnScope: row.scope, - ...(row.ref === undefined ? {} : { providerItemRef: row.ref }) - } - } + return { identity: stream.row.identity, body } } private message(stream: ProviderTimelineStream, text: string): AgentJournalItemBody { @@ -329,18 +268,6 @@ export class ProviderTimelineTextStreams { } } - private itemJoin(item: { id: string }, join?: ProviderTimelineJoin): ProviderTimelineItemJoin { - return { - family: 'item', - key: { source: 'provider', value: item.id }, - thread: join?.thread ?? null - } - } - - private anonymousJoin(join?: ProviderTimelineJoin): ProviderTimelineItemJoin { - return { family: 'item', key: this.deps.context.joins.mint('s'), thread: join?.thread ?? null } - } - private forget(stream: ProviderTimelineStream): void { this.coalescer.forget(stream.id) if (this.streams.get(stream.key) === stream) { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts index 35fd7abc14b5..a51cc9064dac 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts @@ -1,130 +1,87 @@ -// The writes one non-text event may need, laid out before anything is decided: the text owed -// ahead of it, then its fixed settlement and item slots, each resolved when it runs. +// The writes one non-text event's transition carries, in order: the text owed ahead of it, its +// settlement, then its rows. -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import { unhandledProviderFrameJournalItem } from '../agent-session-wire/unhandled-provider-frame' -import type { ProviderTimelineApplyHost } from './provider-timeline-apply-host' +import { + providerTimelineSettlementId, + type ProviderTimelineContext +} from './provider-timeline-context' import type { ProviderTimelineDecidedEvent, ProviderTimelineDecision -} from './provider-timeline-decisions' +} from './provider-timeline-decision' import type { ProviderTimelinePlan } from './provider-timeline-plan' +import { providerKey, turnOf } from './provider-timeline-rows' +import type { ProviderTimelineState } from './provider-timeline-state' +import type { ProviderTimelineTextStreams } from './provider-timeline-text-streams' -/** Room for a settled turn row and its context facts. */ -const TURN_ROW_RESERVED_BYTES = 64 * 1024 -/** Identities a scheme spells stay well under this; item rows reserve it for theirs. */ -const IDENTITY_PLACEHOLDER = { provider: 'orca', clientMessageId: 'x'.repeat(1024) } as const +/** Paces the queue only: a settlement's mutations are the journal's to choose. */ +const SETTLEMENT_RESERVED_BYTES = 64 * 1024 /** Text owed ahead of the event lands first; a row-writing event also ends the messages it * separates: anonymous ones of its producer, every stream of a turn it ends, all on a session end. */ export function planProviderTimelineBarrier( - host: ProviderTimelineApplyHost, + input: { + streams: ProviderTimelineTextStreams + state: ProviderTimelineState + context: ProviderTimelineContext + }, plan: ProviderTimelinePlan, event: ProviderTimelineDecidedEvent, - decision: ProviderTimelineDecision, - /** Execution: whether the event's own decision wrote, so the boundary it draws is real. */ - landed: () => boolean + decision: ProviderTimelineDecision ): void { - const { streams, context } = host - const forecast = host.forecast() + const { streams, state, context } = input // A full snapshot of a streamed message replaces what streamed, so that text is not flushed. const replaced = decision.closes ? streams.get(decision.closes) : undefined streams.planFlush(plan, replaced) if (replaced) { - streams.planRelease(plan, (stream) => stream === replaced, landed) + streams.planRelease(plan, (stream) => stream === replaced) } - if (event.type === 'session.ended' || event.type === 'session.reset') { - streams.planRelease(plan, () => true, landed) - streams.planRetire(plan) + if (event.type === 'session.ended') { + streams.planRelease(plan, () => true) return } if (event.type === 'turn.end' || event.type === 'turn.open') { const ending = event.type === 'turn.end' && event.turn !== undefined - ? context.joins.turn({ source: 'provider', value: event.turn }, forecast.namespace).itemId - : forecast.open?.itemId + ? context.rows.turn(providerKey(event.turn)).itemId + : state.open?.itemId streams.planRelease( plan, (stream) => (!stream.named && stream.producer?.agentId === undefined) || - (ending !== undefined && streams.rowTurn(stream) === ending), - landed + (ending !== undefined && turnOf(stream.scope) === ending) ) - // The turn that really ended is the journal's: its streams stop wherever planning placed them. - streams.planRetire(plan) return } if (event.type !== 'input.accepted') { const agentId = 'producer' in event ? event.producer?.agentId : undefined - streams.planRelease( - plan, - (stream) => !stream.named && stream.producer?.agentId === agentId, - landed - ) + streams.planRelease(plan, (stream) => !stream.named && stream.producer?.agentId === agentId) } } -export function planProviderTimelineSlots( - host: ProviderTimelineApplyHost, +/** The event's own settlement and rows, after the text it flushed. */ +export function planProviderTimelineWrites( + context: ProviderTimelineContext, plan: ProviderTimelinePlan, - event: ProviderTimelineDecidedEvent, - decide: (journal: StructuredAgentSessionTransitionJournal) => ProviderTimelineDecision | null + decision: ProviderTimelineDecision, + serial: () => number ): void { - const { context } = host - const settlement = (what: string) => + const { settle } = decision + if (settle) { plan.settlement({ - settlementId: context.settlementId(what), - reservedBytes: TURN_ROW_RESERVED_BYTES, - resolve: (journal) => decide(journal)?.settle ?? [] + settlementId: providerTimelineSettlementId(context, serial(), settle.what), + reservedBytes: SETTLEMENT_RESERVED_BYTES, + resolve: settle.resolve }) - const item = (reservedBytes: number, lifecycle: boolean) => + } + for (const write of decision.writes ?? []) { plan.item( { - reservedBytes, - resolve: (journal) => decide(journal)?.write ?? null, - options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + reservedBytes: write.reservedBytes, + resolve: write.resolve, + options: write.options }, - lifecycle + write.lifecycle ) - switch (event.type) { - case 'turn.open': - settlement('turn-superseded') - item(TURN_ROW_RESERVED_BYTES, true) - return - case 'turn.end': - settlement('turn-end') - return - case 'request.withdrawn': - settlement('request-withdrawn') - return - case 'session.ended': - settlement('session-end') - return - case 'session.reset': - settlement('session-reset') - return - case 'input.accepted': - case 'context.usage': - item(TURN_ROW_RESERVED_BYTES, true) - return - case 'item.open': - case 'item.update': - case 'item.close': - item( - estimateStructuredAgentSessionItemBytes(IDENTITY_PLACEHOLDER, event.body), - event.type !== 'item.update' - ) - return - case 'request.open': - item(estimateStructuredAgentSessionItemBytes(IDENTITY_PLACEHOLDER, event.body), true) - return - case 'provider.frame': { - const frame = unhandledProviderFrameJournalItem(context.agent, event.frameKind, event.payload) - if (frame) { - item(estimateStructuredAgentSessionItemBytes(IDENTITY_PLACEHOLDER, frame.body), false) - } - } } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts index a6ee99b15bf1..f242a7573e7b 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts @@ -1,4 +1,5 @@ -// What turn, send, context and session events do, decided on the forecast and again on the ledger. +// What turn, user-message, context and session events do: admitted on the state, written +// against the journal. import { AGENT_JOURNAL_THREAD_SCOPE, @@ -9,81 +10,100 @@ import { agentJournalTurnBody, readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { agentJournalTurnRowReservedBytes, resolveAgentJournalTurnRowWrite } from './agent-journal-turn-row-revision' +import { providerTimelinePlacement } from './provider-timeline-context' +import type { + ProviderTimelineDecidedEvent, + ProviderTimelineDecision, + ProviderTimelineDecisionInput, + ProviderTimelineItemWrite, + ProviderTimelineResolvedWrite +} from './provider-timeline-decision' import { providerKey, - settlementOf, - type ProviderTimelineDecidedEvent, - type ProviderTimelineDecision, - type ProviderTimelineDecisionInput, - type ProviderTimelineResolvedWrite -} from './provider-timeline-decision' -import { providerTimelinePlacement } from './provider-timeline-context' -import type { ProviderTimelineTurnRef } from './provider-timeline-joins' + providerTimelineTurnRowState, + type ProviderTimelineTurnRef +} from './provider-timeline-rows' import { providerTimelineSettlement, - runningProviderTimelineTurns, - type ProviderTimelineTurnEnd + runningProviderTimelineTurns } from './provider-timeline-settlement' -import type { ProviderTimelineState } from './provider-timeline-state' +import type { + ProviderTimelineOpenTurn, + ProviderTimelinePendingInput +} from './provider-timeline-state' + +/** Room for a settled turn row and its context facts. */ +const TURN_ROW_RESERVED_BYTES = 64 * 1024 export function decideTurnOpen( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { - const { state, context } = input + const { state, journal, context } = input const open = state.open - const key = event.turn === undefined ? event.minted : providerKey(event.turn) - if (!key || (event.turn === undefined && open)) { + // An open naming no turn while one is open is the same turn, not a new one. + if (event.turn === undefined && open) { return { dropped: 'turn-duplicate' } } - const turn = context.joins.turn(key, state.namespace) - if (open?.itemId === turn.itemId) { + const turn = context.rows.turn( + event.turn === undefined ? context.rows.minted('t', input.serial()) : providerKey(event.turn) + ) + const held = journal ? providerTimelineTurnRowState(journal, turn.itemId) : 'absent' + if (open?.itemId === turn.itemId || held === 'running') { return { dropped: 'turn-duplicate' } } - if (state.status(turn, input.journal) !== 'absent') { - return { dropped: 'turn-replayed' } + if (held === 'settled') { + return { dropped: 'turn-settled' } } const pending = state.opener(turn.itemId) const running: AgentJournalTurnLifecycle = { turnId: turn.turnId, state: 'running', - userItemId: pending - ? agentJournalSubmissionKey(pending.clientMessageId) - : context.joins.turnOpener(turn), + userItemId: pending?.userItemId ?? turn.itemId, startedAt: event.at, - ...(pending ? { requestedAt: pending.requestedAt } : {}) - } - // A newer turn ended this one, whoever asked for it. - const superseded = open - ? settlementOf(input, open, { - state: 'interrupted', - completedAt: event.at, - outcome: 'superseded' - }) - : [] + ...(pending?.requestedAt === undefined ? {} : { requestedAt: pending.requestedAt }) + } + const write: ProviderTimelineResolvedWrite = { + identity: turn.identity, + body: agentJournalTurnBody(running) + } return { - ...(superseded ? { settle: superseded } : {}), - write: input.execute + // A newer turn ended this one, whoever asked for it. + ...(open ? { - identity: turn.identity, - body: agentJournalTurnBody(running), - // The running row's ts is the turn start itself, so clients read no append lag. - options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true, observedAt: event.at } + settle: { + what: 'turn-superseded', + resolve: (journal) => + providerTimelineSettlement(journal, { turnItemId: open.itemId }, [open], { + state: 'interrupted', + completedAt: event.at, + outcome: 'superseded' + }) + } } - : null, + : {}), + writes: [ + { + reservedBytes: TURN_ROW_RESERVED_BYTES, + lifecycle: true, + // The running row's ts is the turn start itself, so clients read no append lag. + options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true, observedAt: event.at }, + // Only where no row is: a turn the journal already holds is never written back to running. + resolve: (at) => (at.itemBody(turn.itemId) === null ? write : null) + } + ], commit: (next) => { if (next.open) { next.endTurn(next.open) } if (pending) { - next.inputs = next.inputs.filter( - (input) => input.clientMessageId !== pending.clientMessageId - ) + next.inputs = next.inputs.filter((each) => each !== pending) } next.open = { ...turn, running } } @@ -96,69 +116,103 @@ export function decideTurnEnd( ): ProviderTimelineDecision { const { state } = input const turn = - event.turn === undefined - ? state.open - : input.context.joins.turn(providerKey(event.turn), state.namespace) + event.turn === undefined ? state.open : input.context.rows.turn(providerKey(event.turn)) if (!turn) { return { dropped: 'no-turn' } } - if (state.status(turn, input.journal) !== 'running') { + // Only the open turn ends here; any other is already over (superseded, or ended by its writer). + if (state.open?.itemId !== turn.itemId) { return { dropped: 'turn-unknown' } } - const settle = settlementOf(input, turn, { - state: event.state, - completedAt: event.at, - ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), - ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) - }) - return { ...(settle ? { settle } : {}), commit: (next) => next.endTurn(turn) } + return { + settle: { + what: 'turn-end', + resolve: (journal) => + providerTimelineSettlement(journal, { turnItemId: turn.itemId }, [turn], { + state: event.state, + completedAt: event.at, + ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), + ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) + }) + }, + commit: (next) => next.endTurn(turn) + } } export function decideInput( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { - const { state, journal, context } = input - const named = event.join?.turn - const placement = providerTimelinePlacement(context, state, event.join) - if (input.execute && journal && placement.turn && event.join?.item !== undefined) { - context.joins.reserveEcho( - { family: 'item', key: providerKey(event.join.item), thread: event.join.thread ?? null }, - placement, - journal - ) + return decideOpener( + input, + { + userItemId: agentJournalSubmissionKey(event.clientMessageId), + requestedAt: event.requestedAt + }, + event.join?.turn + ) +} + +/** A saved user message of an adopted session: its own row, only where none is, and the opener of + * the turn it names. */ +export function decideHistory( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { context, state } = input + const row = context.rows.item('item', providerKey(event.item), event.join.thread ?? null) + const write: ProviderTimelineResolvedWrite = { identity: row.identity, body: event.body } + const opener = decideOpener(input, { userItemId: row.itemId }, event.join.turn) + return { + ...opener, + writes: [ + { + reservedBytes: estimateStructuredAgentSessionItemBytes(row.identity, event.body), + lifecycle: false, + options: { turnScope: providerTimelinePlacement(context, state, event.join) }, + resolve: (at) => (at.itemBody(row.itemId) === null ? write : null) + }, + ...(opener.writes ?? []) + ] } +} + +/** A user message names the turn it opened: the one it names once that one opens, else the open + * turn while that still names no message of its own, else the next to open. */ +function decideOpener( + input: ProviderTimelineDecisionInput, + message: Omit, + named: string | undefined +): ProviderTimelineDecision { + const { state, journal, context } = input const open = state.open - const namedTurn = - named === undefined ? null : context.joins.turn(providerKey(named), state.namespace) - const status = namedTurn ? state.status(namedTurn, journal) : null - // A late echo of a turn already over names nothing. - if (status === 'settled' || (status === 'running' && namedTurn?.itemId !== open?.itemId)) { - return {} - } - const pending = { - clientMessageId: event.clientMessageId, - requestedAt: event.requestedAt, - ...(namedTurn && status === 'absent' ? { turnItemId: namedTurn.itemId } : {}) - } - // No turn open, or the send names one still to open: it waits for that turn. - if (!open || pending.turnItemId !== undefined) { - return { - commit: (next) => next.wait(pending) + const turn = named === undefined ? null : context.rows.turn(providerKey(named)) + if (turn && turn.itemId !== open?.itemId) { + // A late echo of a turn already over names nothing. + if (journal && providerTimelineTurnRowState(journal, turn.itemId) !== 'absent') { + return {} } + return { commit: (next) => next.wait({ ...message, turnItemId: turn.itemId }) } + } + if (!open) { + return { commit: (next) => next.wait(message) } } - // Only a turn still naming its fallback opener takes the send; the journal's row says, once there. const opener = - readAgentJournalTurn(journal?.itemBody(open.itemId) ?? undefined)?.userItemId ?? + (journal && readAgentJournalTurn(journal.itemBody(open.itemId) ?? undefined)?.userItemId) ?? open.running.userItemId - if (opener !== context.joins.turnOpener(open)) { + if (opener !== open.itemId) { return {} } - const userItemId = agentJournalSubmissionKey(event.clientMessageId) - const running = { ...open.running, userItemId, requestedAt: event.requestedAt } + const running = { ...open.running, ...message } return { - write: - input.execute && journal ? reviseOpener(journal, open, userItemId, event.requestedAt) : null, + writes: [ + { + reservedBytes: TURN_ROW_RESERVED_BYTES, + lifecycle: true, + options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true }, + resolve: (at) => reviseOpener(at, open, message) + } + ], commit: (next) => { if (next.open?.itemId === open.itemId) { next.open = { ...next.open, running } @@ -167,51 +221,46 @@ export function decideInput( } } -/** Only the opener fields change; the rest are the row's as the journal holds it. */ -export function reviseOpener( +/** Only while the row runs and still names the turn itself as its opener: an opener another writer + * gave it stands. Every other field is the row's as the journal holds it. */ +function reviseOpener( journal: StructuredAgentSessionTransitionJournal, - open: ProviderTimelineTurnRef, - userItemId: string, - requestedAt: number + open: ProviderTimelineOpenTurn, + message: Omit ): ProviderTimelineResolvedWrite | null { const row = readAgentJournalTurn(journal.itemBody(open.itemId) ?? undefined) - if (row?.state !== 'running') { + if (row?.state !== 'running' || row.userItemId !== open.itemId) { return null } const target = { identity: open.identity } const write = { - lifecycle: agentJournalTurnBody({ ...row, userItemId, requestedAt }), + lifecycle: agentJournalTurnBody({ ...row, ...message }), onlyWhileRunning: true as const } - const resolved = resolveAgentJournalTurnRowWrite( + return resolveAgentJournalTurnRowWrite( journal, target, write, agentJournalTurnRowReservedBytes(target, write) ) - return ( - resolved && { ...resolved, options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } } - ) } export function decideSessionEnd( - input: ProviderTimelineDecisionInput, - end: ProviderTimelineTurnEnd, - commit: (state: ProviderTimelineState) => void + _input: ProviderTimelineDecisionInput, + event: Extract ): ProviderTimelineDecision { - const { journal } = input return { - ...(input.execute && journal - ? { - settle: providerTimelineSettlement( - journal, - 'session', - runningProviderTimelineTurns(journal), - end - ) - } - : {}), - commit + settle: { + what: 'session-end', + resolve: (journal) => + providerTimelineSettlement( + journal, + 'session', + runningProviderTimelineTurns(journal), + event.verdict + ) + }, + commit: (next) => next.endSession() } } @@ -219,24 +268,23 @@ export function decideContextUsage( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { - const { state, journal } = input + const { state } = input const named = event.join?.turn - const turn = - named === undefined - ? (state.open ?? state.latest) - : input.context.joins.turn(providerKey(named), state.namespace) - if (!input.execute || !journal) { - return {} - } + const turn: ProviderTimelineTurnRef | null = + named === undefined ? (state.open ?? state.latest) : input.context.rows.turn(providerKey(named)) const target = turn ? { identity: turn.identity } : ({ newest: true } as const) const write = { contextUsage: event.usage } - const resolved = resolveAgentJournalTurnRowWrite( - journal, - target, - write, - agentJournalTurnRowReservedBytes(target, write) - ) - return { - write: resolved && { ...resolved, options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE } } + const usage: ProviderTimelineItemWrite = { + reservedBytes: agentJournalTurnRowReservedBytes(target, write), + lifecycle: true, + options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE }, + resolve: (journal) => + resolveAgentJournalTurnRowWrite( + journal, + target, + write, + agentJournalTurnRowReservedBytes(target, write) + ) } + return { writes: [usage] } } 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 f0372c8e58de..833cb89424e9 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 @@ -8,13 +8,12 @@ import { STALE_SESSION_ROW_PREFIX } from '../../../shared/agent-session-stop-row import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemBody, type AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' -import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' +import { terminalAgentJournalBody } from '../agent-session-journal/journal-terminal-settlement' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { agentSessionFailureWords, @@ -176,7 +175,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { } for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) - const body = terminalDeadGenerationBody(item) + const body = terminalAgentJournalBody(item.body) if (identity && body) { mutations.push({ kind: 'item', @@ -234,7 +233,7 @@ export async function settleStaleStructuredAgentSessionState(input: { const mutations: JournalLifecycleMutationInput[] = [] for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) - const body = terminalDeadGenerationBody(item) + const body = terminalAgentJournalBody(item.body) if (identity && body) { mutations.push({ kind: 'item', @@ -288,20 +287,10 @@ export async function settleStaleStructuredAgentSessionState(input: { return mutations.length } -function terminalDeadGenerationBody(item: AgentJournalRenderItem): AgentJournalItemBody | null { - if (item.body.kind === 'tool-call' && item.body.state === 'running') { - return { ...item.body, state: 'failed' } - } - if (item.body.kind === 'approval' || item.body.kind === 'question') { - return item.body.resolution.state === 'pending' ? cancelledJournalPromptBody(item.body) : null - } - return null -} - function isUnfinishedItem(item: AgentJournalRenderItem): boolean { return ( readAgentJournalTurn(item.body)?.state === 'running' || - terminalDeadGenerationBody(item) !== null + terminalAgentJournalBody(item.body) !== null ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts index a185695d5ea5..3e2f8449e0d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -32,10 +32,8 @@ export type StructuredAgentSessionTransitionJournal = Pick< export type StructuredAgentSessionTransitionStep = | { kind: 'item' - /** Bounds what `resolve` may write; a larger write fails the sink, unless `paced`. */ + /** Bounds what `resolve` may write; a larger write fails the sink. */ reservedBytes: number - /** The size only paces the queue: the row grows by text the fold holds (a resumed message). */ - paced?: true /** The row and its whole body; null writes nothing. Resolved `options` replace the planned * ones, for a writer that learns the row's turn or producer only from the fold. */ resolve: (journal: StructuredAgentSessionTransitionJournal) => { @@ -105,9 +103,8 @@ function transitionAppend( return null } if ( - !step.paced && estimateStructuredAgentSessionItemBytes(resolved.identity, resolved.body) > - step.reservedBytes + step.reservedBytes ) { throw new Error(STEP_OVERFLOW) } From 9c58983e39922358fca2565c180f6866b4991495 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:37:14 -0700 Subject: [PATCH 127/269] Answer every agent request after an ACP cancel A cancel that lands before a permission handler starts now still runs the permission path, so the agent gets the `cancelled` outcome rather than a request-cancelled error. A handler that ignores the abort no longer leaves the agent waiting: once the abort has run through, any request still unanswered gets request-cancelled. Handlers that answer on abort keep their own reply. Also renames a lint-rejected helper parameter, replaces a Reflect.apply in a test, and stops the permission diagnostic from firing with an empty list. --- src/main/acp/acp-incoming-requests.ts | 27 +++++++--- src/main/acp/acp-json-rpc-peer.ts | 3 +- src/main/acp/acp-permission-requests.test.ts | 54 +++++++++++++++++--- src/main/acp/acp-permission-requests.ts | 11 ++-- src/main/acp/acp-session-lifecycle.test.ts | 23 ++++++--- src/main/acp/acp-session-runtime.ts | 2 + 6 files changed, 94 insertions(+), 26 deletions(-) diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts index 92e50219e515..6b8d21124eb2 100644 --- a/src/main/acp/acp-incoming-requests.ts +++ b/src/main/acp/acp-incoming-requests.ts @@ -28,13 +28,25 @@ export class AcpIncomingRequests { this.open.clear() } - // The handler still answers (a permission answers `cancelled`); -32800 only if it rejects. + // Each handler answers on abort (a permission answers `cancelled`); -32800 if it rejects, or if + // it is still silent once the abort has run through (the next event-loop turn). cancel(): void { - for (const request of this.open.values()) { - if (!request.cancelled) { - request.cancelled = true - request.controller.abort(new AcpRpcError(-32800, 'Request cancelled')) - } + const cancelled = [...this.open].filter(([, request]) => !request.cancelled) + for (const [, request] of cancelled) { + request.cancelled = true + request.controller.abort(new AcpRpcError(-32800, 'Request cancelled')) + } + if (cancelled.length > 0) { + setImmediate(() => { + for (const [id, request] of cancelled) { + if (this.open.get(id) === request) { + this.open.delete(id) + request.closed = true + request.abandon() + void this.sendError(id, new AcpRpcError(-32800, 'Request cancelled')) + } + } + }) } } @@ -62,7 +74,8 @@ export class AcpIncomingRequests { void Promise.race([ abandoned, Promise.resolve().then(() => { - if (controller.signal.aborted) { + // A cancelled request still reaches its handler, so a permission can answer `cancelled`. + if (request.closed) { throw controller.signal.reason } if (!this.handler) { diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index 2a2cb867b08c..0cf0b1fab377 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -33,7 +33,8 @@ const envelopeSchema = z.looseObject({ export type AcpJsonRpcMessage = z.infer export type AcpRequestContext = { id: string | number | null; signal: AbortSignal } export type AcpPeerHandlers = { - // Void means handled; unsupported methods must throw AcpRpcError(-32601). + // Void means handled; unsupported methods must throw AcpRpcError(-32601). On a cancel abort, + // answer (e.g. the protocol's own cancelled reply) or throw at once: silence is answered -32800. onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown onNotification?: (method: string, params: unknown) => void onDiagnostic?: (message: string) => void diff --git a/src/main/acp/acp-permission-requests.test.ts b/src/main/acp/acp-permission-requests.test.ts index f51a7429853c..d2f15611725b 100644 --- a/src/main/acp/acp-permission-requests.test.ts +++ b/src/main/acp/acp-permission-requests.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' +import type { AcpPermissionHandler } from './acp-permission-requests' import type { RequestPermissionRequest } from './generated/acp-protocol.generated' const opened: { close: () => void }[] = [] @@ -66,11 +67,11 @@ describe('ACP permission requests', () => { options: [{ optionId: 'allow', name: 'allow', kind: 'allow_once' }] }) expect(diagnostics).toEqual([ - 'Delivered ACP permission request without unreadable fields: options.1, toolCall.content' + 'Delivered ACP permission request without unreadable fields: options.0.name, options.1, toolCall.content' ]) }) - it.each([ + it.each<[string, AcpPermissionHandler, string]>([ [ 'the handler throws', () => { @@ -78,12 +79,14 @@ describe('ACP permission requests', () => { }, 'handler failed: Error: renderer gone' ], - ['the handler answers nonsense', () => ({ outcome: 'yes' }), 'invalid handler response'] + // JSON.parse yields an untyped value, so a deliberately malformed reply needs no assertion. + [ + 'the handler answers nonsense', + () => JSON.parse('{"outcome":"yes"}'), + 'invalid handler response' + ] ])('answers cancelled with a diagnostic when %s', async (_label, onPermission, problem) => { - // Reflect.apply returns any, so a deliberately ill-typed answer needs no type assertion. - const handler: AcpSessionRuntimeOptions['onPermission'] = (request, context) => - Reflect.apply(onPermission, undefined, [request, context]) - const { agent, runtime, diagnostics } = fixture({ onPermission: handler }) + const { agent, runtime, diagnostics } = fixture({ onPermission }) await runtime.start(startOptions) void runtime.prompt([{ type: 'text', text: 'hi' }]).catch(() => {}) expect( @@ -96,6 +99,43 @@ describe('ACP permission requests', () => { expect(diagnostics).toEqual([`Answered ACP permission request cancelled: ${problem}`]) }) + it('answers cancelled when the cancel lands before the permission handler starts', async () => { + const asked = vi.fn((_request: RequestPermissionRequest) => new Promise(() => {})) + const { agent, runtime } = fixture({ onPermission: asked }) + await runtime.start(startOptions) + runtime.subscribe(() => void runtime.cancel()) + const answer = new Promise((resolve) => { + agent.stdin.on('data', (chunk: string) => { + if (chunk.includes('"id":5')) { + resolve(JSON.parse(chunk)) + } + }) + }) + // One chunk: the permission, then an update whose listener cancels before the handler runs. + agent.stdout.write( + [ + { + jsonrpc: '2.0', + id: 5, + method: 'session/request_permission', + params: { sessionId: 'session-1', toolCall, options: [allow] } + }, + { + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId: 'session-1', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'x' } } + } + } + ] + .map((frame) => `${JSON.stringify(frame)}\n`) + .join('') + ) + expect(await answer).toMatchObject({ id: 5, result: { outcome: { outcome: 'cancelled' } } }) + expect(asked).not.toHaveBeenCalled() + }) + it('lets a turn the agent began itself ask for permission and cancels it with session/cancel', async () => { const asked = deferred() const { agent, runtime } = fixture({ diff --git a/src/main/acp/acp-permission-requests.ts b/src/main/acp/acp-permission-requests.ts index c39f4a4c9022..4c4aa0b8f025 100644 --- a/src/main/acp/acp-permission-requests.ts +++ b/src/main/acp/acp-permission-requests.ts @@ -24,14 +24,14 @@ const optionRoutingSchema = z.looseObject({ optionId: z.string() }) // Keeps every field the schema does not know or can read; drops (and names) the unreadable ones. function readableFields( - shape: Record, + knownFields: Record, value: Record, path: string, dropped: string[] ): Record { const kept: Record = {} for (const [key, field] of Object.entries(value)) { - if (!Object.hasOwn(shape, key) || shape[key].safeParse(field).success) { + if (!Object.hasOwn(knownFields, key) || knownFields[key].safeParse(field).success) { kept[key] = field } else { dropped.push(`${path}${key}`) @@ -61,6 +61,9 @@ export function readAcpPermissionRequest( dropped.push(`options.${index}`) return [] } + if (typeof ids.data.name !== 'string') { + dropped.push(`options.${index}.name`) + } const named = { ...ids.data, name: typeof ids.data.name === 'string' ? ids.data.name : ids.data.optionId @@ -83,7 +86,9 @@ export function readAcpPermissionRequest( if (!request.success) { return null } - diagnose(`Delivered ACP permission request without unreadable fields: ${dropped.join(', ')}`) + if (dropped.length > 0) { + diagnose(`Delivered ACP permission request without unreadable fields: ${dropped.join(', ')}`) + } return request.data } diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts index 092ed11e186c..ec36afe471d5 100644 --- a/src/main/acp/acp-session-lifecycle.test.ts +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -247,16 +247,19 @@ describe('ACP caller-owned waits', () => { await expect(authenticated).resolves.toEqual({}) }) - it('lets cancel abort vendor hooks so each sends its own answer, or request-cancelled if it rejects', async () => { + it('lets cancel abort vendor hooks so each sends its own answer, else request-cancelled', async () => { const { agent, runtime } = fixture({ onRequest: (method, _params, context) => - new Promise((resolve, reject) => - context.signal.addEventListener('abort', () => - method === '_vendor/plan' - ? resolve({ outcome: 'abandoned' }) - : reject(new Error('stop')) - ) - ) + new Promise((resolve, reject) => { + // '_vendor/silent' ignores the abort: the runtime still answers the agent. + if (method !== '_vendor/silent') { + context.signal.addEventListener('abort', () => + method === '_vendor/plan' + ? resolve({ outcome: 'abandoned' }) + : reject(new Error('stop')) + ) + } + }) }) agent.on('session/prompt', (frame) => agent.on('session/cancel', () => agent.reply(frame, { stopReason: 'cancelled' })) @@ -265,11 +268,15 @@ describe('ACP caller-owned waits', () => { const pending = runtime.prompt([...prompt]) const question = agent.request('question', '_vendor/question', {}) const plan = agent.request('plan', '_vendor/plan', {}) + const silent = agent.request('silent', '_vendor/silent', {}) await tick() await runtime.cancel() expect(await question).toMatchObject({ error: { code: -32800 } }) expect(await plan).toMatchObject({ result: { outcome: 'abandoned' } }) + expect(await silent).toMatchObject({ error: { code: -32800 } }) await pending + await tick() + expect(agent.frames.filter((frame) => frame.id === 'plan')).toHaveLength(1) }) it('settles requests when the process owner closes on exit even if stdout stays open', async () => { diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 7d185c108c4d..718fd2c716e4 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -52,6 +52,8 @@ export type AcpSessionRuntimeOptions = { peer?: AcpPeerOptions cancelTimeoutMs?: number onPermission?: AcpPermissionHandler + /** Agent requests other than permissions. When `context.signal` aborts on cancel, answer with + * the agent's own cancelled reply or throw right away; a handler still silent gets -32800. */ onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown /** Agent notifications other than `session/update` (protocol extensions), delivered * synchronously in arrival order with the `subscribe` events. */ From 6e9d0c0ff7b1fa58ef5b8722943e91114ae0d854 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:37:30 -0700 Subject: [PATCH 128/269] Cover new running work in a turn the sweep ended --- .../provider-timeline-assembler-restart.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts index 5b4033e3ff6d..9772b13fc128 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts @@ -124,6 +124,12 @@ describe('a resumed session', () => { }) const late = { type: 'item.update', item: 'call-a', body: runningTool('read') } as const assembler.apply({ ...late, join: { turn: 'old' } }) + assembler.apply({ + type: 'item.open', + item: 'call-b', + body: runningTool('grep'), + join: { turn: 'old' } + }) assembler.apply({ type: 'turn.open', turn: 'new', at: 3_000 }) assembler.apply({ type: 'request.open', request: '0', body: pendingApproval }) // The sweep for the dead child lands before the drain. @@ -141,6 +147,8 @@ describe('a resumed session', () => { expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ state: 'failed' }) + // Nor does new running work land in a turn the sweep ended: nothing would ever settle it. + expect(await rig.row(providerItemId('item', 'call-b'))).toBeUndefined() expect((await rig.row(providerItemId('request', '0')))?.body).toMatchObject({ resolution: { state: 'cancelled' } }) From fc8541f5dd1f1cd1fd293e7085413d0097747ef4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:49:41 -0700 Subject: [PATCH 129/269] Run a transition's steps in one queued write that loops over them The steps of one event now share one turn in the journal's write queue: a loop writes each in its own transaction through the row writer's synchronous writeRows (split out of enqueueRows) and stops at the first throw. Prefix semantics and "nothing lands between the steps" now hold by construction, so the completed-write counter on the queue, the step gate and the allSettled barrier are gone; the queue is back to main's bytes. --- .../journal-lifecycle-batch-appender.ts | 38 ++--- .../journal-row-writer.ts | 55 ++++---- .../journal-step-writer.ts | 58 ++++++++ .../journal-store-collaborators.ts | 17 ++- .../agent-session-journal/journal-store.ts | 14 +- .../journal-write-queue.ts | 16 +-- ...tructured-agent-session-transition.test.ts | 132 +++++++++++++++++- .../structured-agent-session-transition.ts | 94 ++++--------- 8 files changed, 274 insertions(+), 150 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-step-writer.ts diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts index cc300a7b7497..3cac8d03c211 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts @@ -78,30 +78,20 @@ export class JournalLifecycleBatchAppender { }) } - /** Chooses the mutations at its turn in the queue; one too large for a row becomes consecutive - * rows, committed in one transaction, in the order resolved. */ - appendResolved(input: JournalResolvedLifecycleBatchInput): Promise { - return this.deps - .enqueueRows(() => { - const mutations = input.resolve() - // Every chunk is built before any commits, so a second chunk naming the same item would - // reuse the first chunk's revision. - this.assertDistinctItems(mutations) - return partitionJournalLifecycleMutations(input.settlementId, mutations) - .filter((chunk) => !this.wasApplied(chunk.settlementId)) - .map((chunk) => - journalLifecycleBatchRowBuilder( - this.deps.state, - chunk.settlementId, - chunk.mutations, - input - ) - ) - }) - .then((rows) => { - const last = rows.at(-1) - return last ? { epoch: last.epoch, sequence: last.seq } : null - }) + /** The rows a resolved settlement writes, planned at its own turn in the queue: its mutations, + * chosen then, in as many consecutive rows as they need, minus any already applied. */ + planResolved( + input: JournalResolvedLifecycleBatchInput + ): ((seq: number, ts: number) => JournalRow)[] { + const mutations = input.resolve() + // Every chunk is built before any commits, so a second chunk naming the same item would + // reuse the first chunk's revision. + this.assertDistinctItems(mutations) + return partitionJournalLifecycleMutations(input.settlementId, mutations) + .filter((chunk) => !this.wasApplied(chunk.settlementId)) + .map((chunk) => + journalLifecycleBatchRowBuilder(this.deps.state, chunk.settlementId, chunk.mutations, input) + ) } private assertDistinctItems(mutations: readonly JournalLifecycleMutationInput[]): void { diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index dde1a66741d1..60db6e0cbd1e 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -76,33 +76,36 @@ export class JournalRowWriter { enqueueRows( plan: () => readonly ((seq: number, ts: number) => JournalRow)[] ): Promise { - return this.deps.serialize(() => { - assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) - const first = this.deps.nextSequence() - const ts = this.deps.now() - const rows = plan().map((build, index) => build(first + index, ts)) - if (rows.length === 0) { - return rows - } - for (const row of rows) { - assertJournalFence(row.fence, this.deps.highestFence()) - } - try { - this.deps.database().transaction((db) => { - for (const row of rows) { - insertJournalRow(db, this.deps.sessionId, row) - this.runBookkeeping(db, row) - } - }) - } catch (error) { - this.deps.rolledBack?.() - throw error - } - for (const row of rows) { - this.deps.commit(row) - } + return this.deps.serialize(() => this.writeRows(plan)) + } + + /** `enqueueRows`' write, for a caller already running at its own turn in the queue. */ + writeRows(plan: () => readonly ((seq: number, ts: number) => JournalRow)[]): JournalRow[] { + assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) + const first = this.deps.nextSequence() + const ts = this.deps.now() + const rows = plan().map((build, index) => build(first + index, ts)) + if (rows.length === 0) { return rows - }) + } + for (const row of rows) { + assertJournalFence(row.fence, this.deps.highestFence()) + } + try { + this.deps.database().transaction((db) => { + for (const row of rows) { + insertJournalRow(db, this.deps.sessionId, row) + this.runBookkeeping(db, row) + } + }) + } catch (error) { + this.deps.rolledBack?.() + throw error + } + for (const row of rows) { + this.deps.commit(row) + } + return rows } /** Assign the next sequence, make the row durable, and fold it through the SAME reducer diff --git a/src/main/native-chat/agent-session-journal/journal-step-writer.ts b/src/main/native-chat/agent-session-journal/journal-step-writer.ts new file mode 100644 index 000000000000..c0b62ce50c4c --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-step-writer.ts @@ -0,0 +1,58 @@ +// Several writes run as ONE turn in the chat's write queue, one after another. +// +// Each step is planned from the fold with every step before it landed, and commits in its own +// transaction. The steps share one queue body, so no other write lands between them, and the +// first step that throws ends the run: the steps before it stay written and none after it runs. + +import type { + JournalItemAppendOptions, + JournalResolvedLifecycleBatchInput +} from './journal-store-contracts' +import type { JournalResolvedItem } from './journal-item-appender' +import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' +import type { JournalReducerState } from './journal-reducer' +import { journalItemRowBuilder } from './journal-row-builders' +import type { JournalRow } from './journal-row-schema' +import type { JournalRowWriter } from './journal-row-writer' +import type { JournalWriteBody } from './journal-write-queue' + +export type JournalStep = + | { + kind: 'item' + /** Read at the step's turn; null writes nothing. */ + resolve: () => JournalResolvedItem | null + options: JournalItemAppendOptions + } + | { kind: 'settlement'; batch: JournalResolvedLifecycleBatchInput } + +export class JournalStepWriter { + constructor( + private readonly deps: { + serialize: (run: JournalWriteBody) => Promise + state: () => JournalReducerState + writeRows: JournalRowWriter['writeRows'] + planSettlement: JournalLifecycleBatchAppender['planResolved'] + } + ) {} + + /** Whether each step wrote; rejects with the error of the step that threw. */ + append(steps: readonly JournalStep[]): Promise { + return this.deps.serialize(() => { + const wrote: boolean[] = [] + for (const step of steps) { + wrote.push(this.deps.writeRows(() => this.plan(step)).length > 0) + } + return wrote + }) + } + + private plan(step: JournalStep): ((seq: number, ts: number) => JournalRow)[] { + if (step.kind === 'settlement') { + return this.deps.planSettlement(step.batch) + } + const resolved = step.resolve() + return resolved === null + ? [] + : [journalItemRowBuilder(this.deps.state, resolved.identity, resolved.body, step.options)] + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index ba8796964692..e5f521e49aea 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -17,6 +17,7 @@ import { JournalStopMarks } from './journal-stop-marks' import { journalQueuePauseRestatement } from './queued-message-pause' import type { JournalReducerState } from './journal-reducer' import { JournalRowWriter } from './journal-row-writer' +import { JournalStepWriter } from './journal-step-writer' import { restoreJournalStore } from './journal-store-restore' import type { JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' @@ -57,6 +58,7 @@ export type JournalStoreCollaborators = { epochController: JournalEpochController itemAppender: JournalItemAppender lifecycleBatchAppender: JournalLifecycleBatchAppender + stepWriter: JournalStepWriter queuedMessages: JournalQueuedMessages stopMarks: JournalStopMarks /** Restores the store's state from disk. Owned here because it needs the same @@ -106,6 +108,12 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row), rolledBack: () => queuedMessages.invalidate() }) + const lifecycleBatchAppender = new JournalLifecycleBatchAppender({ + state: host.state, + cursor: host.cursor, + enqueue: host.enqueue, + enqueueRows: (plan) => rowWriter.enqueueRows(plan) + }) return { epochController, queuedMessages, @@ -121,11 +129,12 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal state: host.state, enqueue: host.enqueue }), - lifecycleBatchAppender: new JournalLifecycleBatchAppender({ + lifecycleBatchAppender, + stepWriter: new JournalStepWriter({ + serialize: host.serialize, state: host.state, - cursor: host.cursor, - enqueue: host.enqueue, - enqueueRows: (plan) => rowWriter.enqueueRows(plan) + writeRows: (plan) => rowWriter.writeRows(plan), + planSettlement: (batch) => lifecycleBatchAppender.planResolved(batch) }) } } diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 3cabb7bd53ef..3edfbf0c1a75 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -71,6 +71,7 @@ import { createJournalStoreCollaborators } from './journal-store-collaborators' import { journalStoreLoadedFields } from './journal-store-open' import type { JournalItemAppender } from './journal-item-appender' import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' +import type { JournalStepWriter } from './journal-step-writer' import type { JournalStopMarks } from './journal-stop-marks' export { AgentSessionJournalError } from './journal-write-guards' @@ -92,6 +93,7 @@ export class AgentSessionJournal { private readonly epochController: JournalEpochController private readonly itemAppender: JournalItemAppender private readonly lifecycleBatchAppender: JournalLifecycleBatchAppender + private readonly stepWriter: JournalStepWriter private readonly restore: () => Promise /** Draft rows queued while the agent works; never reducer input or owed work. */ readonly queuedMessages: JournalQueuedMessages @@ -143,6 +145,7 @@ export class AgentSessionJournal { this.epochController = collaborators.epochController this.itemAppender = collaborators.itemAppender this.lifecycleBatchAppender = collaborators.lifecycleBatchAppender + this.stepWriter = collaborators.stepWriter this.queuedMessages = collaborators.queuedMessages this.stopMarks = collaborators.stopMarks this.restore = collaborators.restore @@ -208,11 +211,6 @@ export class AgentSessionJournal { return this.queue.owing } - /** Write bodies that returned so far; see `JournalWriteQueue.completedWrites`. */ - get completedWrites(): number { - return this.queue.completedWrites - } - cursor = (): AgentJournalCursor => ({ epoch: this.state.epoch, sequence: this.state.lastSequence @@ -336,10 +334,8 @@ export class AgentSessionJournal { return this.lifecycleBatchAppender.append(input) } - /** A lifecycle batch whose mutations are chosen from the fold at its own turn in the queue; - * null when none are. */ - appendResolvedLifecycleBatch: JournalLifecycleBatchAppender['appendResolved'] = (input) => - this.lifecycleBatchAppender.appendResolved(input) + /** Several writes as one turn in the queue; see `JournalStepWriter`. */ + appendSteps: JournalStepWriter['append'] = (steps) => this.stepWriter.append(steps) /** * Write-ahead submission row. It is durable before the caller dispatches diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.ts index ae4f5c96d893..74ef33472bd4 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.ts @@ -30,7 +30,6 @@ export class JournalWriteQueue { private closed = false /** Runs before the next write, and stays owed until it succeeds. */ private owed: (() => Promise) | null = null - private completed = 0 constructor(private readonly sessionId: string) {} @@ -42,20 +41,9 @@ export class JournalWriteQueue { if (this.closed) { return Promise.reject(this.closedError()) } - const counted = (): JournalWriteResult => { - const result = run() - this.completed++ - return result - } return this.owed !== null || this.lineBusy - ? this.join(counted, this.owed !== null) - : this.runNow(counted) - } - - /** Write bodies that returned without throwing. Bodies run one at a time, so a body that reads - * this learns synchronously whether the write just ahead of it landed. */ - get completedWrites(): number { - return this.completed + ? this.join(run, this.owed !== null) + : this.runNow(run) } /** Runs `read` after every write admitted before it, whatever each one's outcome, and ahead of any diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts index b4232642dee0..6698b4cef5ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -13,6 +13,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import { openJournalOwingImport } from '../agent-session-journal/journal-owed-import-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createAgentSessionDeltaCoalescer } from './agent-session-delta-coalescer' import { @@ -86,6 +87,36 @@ async function rig(watermarks: Partial = { return { root, journal, deferred, sink: deferred.sink, publishes, bind } } +/** A bound sink over a chat whose copy into the host's database is still owed, so every write, + * the transition's included, waits in the journal's queue until the first one pays it. */ +async function owedRig() { + const root = await mkdtemp(join(tmpdir(), 'orca-transition-owed-')) + roots.push(root) + const { journal } = await openJournalOwingImport({ + stateDirectory: root, + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: { kind: 'opaque', agent: 'grok', value: 'provider-session-1' } + }, + now: () => 1_000 + }) + const failures: unknown[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(SESSION), + onFailed: (error) => failures.push(error) + }) + deferred.bind({ journal, fence: 1, publish: () => undefined }) + const ownKeys = () => + journal + .snapshot() + .items.map((item) => item.itemId) + .filter((itemId) => itemId.includes(SESSION)) + return { journal, deferred, sink: deferred.sink, failures, ownKeys, close: () => journal.close() } +} + describe('structured agent-session transitions', () => { it('lands its steps back to back, each resolved after the one before it', async () => { const { journal, deferred, sink, publishes, bind } = await rig() @@ -354,6 +385,92 @@ BEGIN SELECT RAISE(ABORT, 'second chunk refused'); END`) expect(journal.snapshot().items).toEqual([]) expect(publishes).toEqual([]) }) + + it('lands a write a step issues while it runs after the whole transition', async () => { + const { journal, deferred, sink, bind } = await rig() + bind() + sink.tryAppendTransition?.({ + lifecycle: false, + publish: false, + steps: [ + itemStep(() => { + // Another writer, reached from inside the step: it waits for the transition's turn to end. + sink.tryAppendItem?.(identity('nested'), tool('nested', 'running'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + return { identity: identity('first'), body: tool('first', 'running') } + }), + itemStep(() => ({ identity: identity('second'), body: tool('second', 'running') })) + ] + }) + await deferred.drained() + + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual( + ['first', 'second', 'nested'].map((recordId) => agentJournalItemKey(identity(recordId))) + ) + }) + + it('runs its steps back to back after an owed import, ahead of a write issued after it', async () => { + const { journal, deferred, sink, ownKeys, close } = await owedRig() + try { + sink.tryAppendTransition?.({ + lifecycle: false, + publish: false, + steps: [ + itemStep(() => ({ identity: identity('first'), body: tool('read', 'running') })), + itemStep((view) => + view.itemBody(agentJournalItemKey(identity('first'))) + ? { identity: identity('second'), body: tool('read', 'running') } + : null + ) + ] + }) + sink.tryAppendItem?.(identity('later'), tool('later', 'running'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + expect(journal.importPending).toBe(true) + await expect(deferred.drained()).resolves.toEqual({ ok: true }) + + expect(journal.importPending).toBe(false) + expect(ownKeys()).toEqual( + ['first', 'second', 'later'].map((recordId) => agentJournalItemKey(identity(recordId))) + ) + } finally { + await close() + } + }) + + it('stops at a failed middle step after an owed import; a write accepted before the failure still lands', async () => { + const { deferred, sink, failures, ownKeys, close } = await owedRig() + try { + const third = vi.fn(() => ({ identity: identity('third'), body: tool('read', 'running') })) + sink.tryAppendTransition?.({ + lifecycle: false, + publish: false, + steps: [ + itemStep(() => ({ identity: identity('first'), body: tool('read', 'running') })), + itemStep( + () => ({ identity: identity('second'), body: tool('x'.repeat(10_000), 'running') }), + 64 + ), + itemStep(third) + ] + }) + // Accepted before the failure is known: the sink's existing rule lets it land. + sink.tryAppendItem?.(identity('later'), tool('later', 'running'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + + expect(ownKeys()).toEqual( + ['first', 'later'].map((recordId) => agentJournalItemKey(identity(recordId))) + ) + expect(third).not.toHaveBeenCalled() + expect(failures).toHaveLength(1) + } finally { + await close() + } + }) }) describe('resolved lifecycle batches', () => { @@ -363,11 +480,16 @@ describe('resolved lifecycle batches', () => { const twice = identity('twice') await expect( - journal.appendResolvedLifecycleBatch({ - settlementId: 'twice', - fence: 1, - resolve: () => [failedTool(identity('once')), failedTool(twice), failedTool(twice)] - }) + journal.appendSteps([ + { + kind: 'settlement', + batch: { + settlementId: 'twice', + fence: 1, + resolve: () => [failedTool(identity('once')), failedTool(twice), failedTool(twice)] + } + } + ]) ).rejects.toThrow('journal_resolved_lifecycle_batch_names_item_twice') expect(journal.cursor().sequence).toBe(before) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts index 57c0c7d0f5e8..f13b897f12bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -3,11 +3,10 @@ // A producer that keeps state about what it wrote must change that state only for writes the sink // took; when an event needs several rows, a refusal of the third after the first two were taken // would leave the producer and the journal disagreeing. A transition is admitted whole or not at -// all. At execution its steps are issued in the same tick, so they sit together in the journal's -// write queue, and each step resolves against the fold with every earlier write landed — the -// steps before it included — so what a step writes is decided by the journal, not by memory. -// Admitted whole, executed as a prefix: once a step fails, the steps after it write nothing, the -// steps before it stay written, and the sink fails, so nothing more is admitted after it. +// all. At execution its steps run as one turn in the journal's write queue, each resolved against +// the fold with every earlier write landed (the steps before it included), so what a step writes +// is decided by the journal, not by memory. Admitted whole, executed as a prefix: once a step +// fails, the steps after it never run, the steps before it stay written, and the sink fails. import type { AgentJournalItemBody, @@ -15,6 +14,7 @@ import type { } from '../../../shared/agent-session-journal-types' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { JournalStep } from '../agent-session-journal/journal-step-writer' import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' import type { StructuredAgentSessionItemAppendOptions, @@ -90,79 +90,37 @@ function transitionAppend( lifecycle: transition.lifecycle, run: async (bound) => { const { journal, fence } = bound - const gate = createStepGate(journal) - // Issued in one tick, so no write submitted after this transition lands between its steps. - const writes = transition.steps.map((step, index) => - step.kind === 'item' - ? journal - .appendResolvedItem( - () => { - const resolved = gate.open(index) ? step.resolve(journal) : null - if (!resolved) { - gate.wroteNothing() - return null - } + const wrote = await journal.appendSteps( + transition.steps.map((step): JournalStep => + step.kind === 'item' + ? { + kind: 'item', + resolve: () => { + const resolved = step.resolve(journal) if ( + resolved && estimateStructuredAgentSessionItemBytes(resolved.identity, resolved.body) > - step.reservedBytes + step.reservedBytes ) { throw new Error(STEP_OVERFLOW) } return resolved }, - structuredAgentSessionJournalAppendOptions(fence, step.options) - ) - .then((landed) => landed !== null) - : journal - .appendResolvedLifecycleBatch({ - settlementId: step.settlementId, - resolve: () => (gate.open(index) ? step.resolve(journal) : []), - fence - }) - .then((landed) => landed !== null) - ) - // Every step, the ones a failure turned into no-ops included, has run before this settles. - const outcomes = await Promise.allSettled(writes) - const failure = outcomes.find( - (outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected' + options: structuredAgentSessionJournalAppendOptions(fence, step.options) + } + : { + kind: 'settlement', + batch: { + settlementId: step.settlementId, + fence, + resolve: () => step.resolve(journal) + } + } + ) ) - if (failure) { - throw failure.reason - } - if ( - transition.publish && - outcomes.some((outcome) => outcome.status === 'fulfilled' && outcome.value) - ) { + if (transition.publish && wrote.includes(true)) { bound.publish() } } }) } - -/** Lets a step write only when every step before it landed. Steps run in queue order, so each - * step's write learns synchronously, from the journal's count of completed writes, whether the - * write handed over just ahead of it landed; a promise would say so only after it ran. */ -function createStepGate(journal: Pick): { - /** First thing step `index` does at its turn; false once an earlier step failed. */ - open: (index: number) => boolean - /** The step writes nothing, so whether its write completes says nothing about a failure. */ - wroteNothing: () => void -} { - let failed = false - let reached = 0 - let handedAt: number | null = null - return { - open: (index) => { - // A step that never reached its resolve failed before it: a refused import, a read-only journal. - if (reached !== index || (handedAt !== null && journal.completedWrites === handedAt)) { - failed = true - } - reached = index + 1 - handedAt = failed ? null : journal.completedWrites - return !failed - }, - wroteNothing: () => { - handedAt = null - } - } -} From 52becf72cb26f07a74e98fae67439a51fc9d4516 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:03:45 -0700 Subject: [PATCH 130/269] Claim no observation from an empty process group ESRCH from the dedicated-group signal says only that the group is empty: a descendant that left it, or a root that never led it, may still be running. It now reports no observation instead of `exited`. The unreadable-table comment says why that case stays "no observation" for root-only providers. --- .../provider-process/provider-process-teardown.test.ts | 4 ++-- src/main/provider-process/provider-process-teardown.ts | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/provider-process/provider-process-teardown.test.ts b/src/main/provider-process/provider-process-teardown.test.ts index 8bf93efac3c6..75e181aaa46d 100644 --- a/src/main/provider-process/provider-process-teardown.test.ts +++ b/src/main/provider-process/provider-process-teardown.test.ts @@ -194,7 +194,7 @@ describe('terminateProviderProcessTree', () => { expect(target.kill).toHaveBeenLastCalledWith('SIGKILL') }) - it('reports an ESRCH dedicated group as exited and a spawnless child as unverifiable', async () => { + it('claims no observation from an ESRCH dedicated group and reports a spawnless child as unverifiable', async () => { await expect( terminateProviderProcessTree(child(), { site: 'provider-test-teardown', @@ -204,7 +204,7 @@ describe('terminateProviderProcessTree', () => { throw Object.assign(new Error('gone'), { code: 'ESRCH' }) } }) - ).resolves.toBe('exited') + ).resolves.toBeNull() const spawnless = { pid: undefined, kill: vi.fn(() => true) } await expect( terminateProviderProcessTree(spawnless, { site: 'provider-test-teardown', platform: 'linux' }) diff --git a/src/main/provider-process/provider-process-teardown.ts b/src/main/provider-process/provider-process-teardown.ts index a8b944cab998..7d30586777b0 100644 --- a/src/main/provider-process/provider-process-teardown.ts +++ b/src/main/provider-process/provider-process-teardown.ts @@ -34,9 +34,9 @@ function terminateDedicatedPosixGroup( try { signalGroup(rootPid, 'SIGKILL') } catch (error) { - // ESRCH is the kernel reporting no process left in the group: positive evidence of absence. + // ESRCH says only that the group is empty; a descendant that left it, or a root that never led it, may live. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Node process.kill errors expose an optional errno code; only that field is read. - return (error as NodeJS.ErrnoException).code === 'ESRCH' ? 'exited' : 'unverifiable' + return (error as NodeJS.ErrnoException).code === 'ESRCH' ? null : 'unverifiable' } // Outside the try: that catch is the ESRCH contract, not a breadcrumb handler. recordSelfInitiatedTreeKill({ @@ -57,7 +57,8 @@ async function terminatePosixTree( const capture = deps.captureDescendants ?? captureDescendantSnapshot const snapshot = await capture(rootPid).catch(() => null) if (!snapshot) { - // No readable table, so no descendant was seen either way. + // No observation rather than the reaper's `unverifiable`: Codex's diagnostic treated this as accepted. + // The reaper-move follow-up maps it to `unverifiable` and takes that Codex change deliberately. child.kill('SIGKILL') return null } From 88490e58cf3aefd9276083912c9a0e3746779332 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:04:37 -0700 Subject: [PATCH 131/269] Let each ACP request handler own its answer after a cancel Removes the next-event-loop-turn fallback that answered request-cancelled for any handler still silent after a cancel. It raced answers that were still being saved (an approval mid-journal-write reached the agent as an error) and made the outcome depend on event-loop timing. The handler that owns an agent request now always sends its answer, or throws for request-cancelled; a request it never answers ends when the connection closes. A permission whose handler had not started still answers `cancelled`. --- src/main/acp/acp-incoming-requests.ts | 25 ++++-------- src/main/acp/acp-json-rpc-peer.ts | 4 +- src/main/acp/acp-session-lifecycle.test.ts | 45 ++++++++++++++++++++-- src/main/acp/acp-session-runtime.ts | 5 ++- 4 files changed, 53 insertions(+), 26 deletions(-) diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts index 6b8d21124eb2..25c473f6bb59 100644 --- a/src/main/acp/acp-incoming-requests.ts +++ b/src/main/acp/acp-incoming-requests.ts @@ -28,25 +28,14 @@ export class AcpIncomingRequests { this.open.clear() } - // Each handler answers on abort (a permission answers `cancelled`); -32800 if it rejects, or if - // it is still silent once the abort has run through (the next event-loop turn). + // Each handler answers its own request (a permission answers `cancelled`); -32800 only if it + // throws. The runtime never answers for a live handler: an answer still being saved must win. cancel(): void { - const cancelled = [...this.open].filter(([, request]) => !request.cancelled) - for (const [, request] of cancelled) { - request.cancelled = true - request.controller.abort(new AcpRpcError(-32800, 'Request cancelled')) - } - if (cancelled.length > 0) { - setImmediate(() => { - for (const [id, request] of cancelled) { - if (this.open.get(id) === request) { - this.open.delete(id) - request.closed = true - request.abandon() - void this.sendError(id, new AcpRpcError(-32800, 'Request cancelled')) - } - } - }) + for (const request of this.open.values()) { + if (!request.cancelled) { + request.cancelled = true + request.controller.abort(new AcpRpcError(-32800, 'Request cancelled')) + } } } diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index 0cf0b1fab377..b4d57e246cd5 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -33,8 +33,8 @@ const envelopeSchema = z.looseObject({ export type AcpJsonRpcMessage = z.infer export type AcpRequestContext = { id: string | number | null; signal: AbortSignal } export type AcpPeerHandlers = { - // Void means handled; unsupported methods must throw AcpRpcError(-32601). On a cancel abort, - // answer (e.g. the protocol's own cancelled reply) or throw at once: silence is answered -32800. + // Void means handled; unsupported methods must throw AcpRpcError(-32601). The handler owns its + // request: once the signal aborts it still answers, or throws (-32800); unanswered ends at close(). onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown onNotification?: (method: string, params: unknown) => void onDiagnostic?: (message: string) => void diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts index ec36afe471d5..7230870a8c02 100644 --- a/src/main/acp/acp-session-lifecycle.test.ts +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises' import { afterEach, describe, expect, it, vi } from 'vitest' import { AcpJsonRpcPeer } from './acp-json-rpc-peer' import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' @@ -247,11 +248,11 @@ describe('ACP caller-owned waits', () => { await expect(authenticated).resolves.toEqual({}) }) - it('lets cancel abort vendor hooks so each sends its own answer, else request-cancelled', async () => { + it('lets cancel abort vendor hooks so each sends its own answer, or request-cancelled if it throws', async () => { const { agent, runtime } = fixture({ onRequest: (method, _params, context) => new Promise((resolve, reject) => { - // '_vendor/silent' ignores the abort: the runtime still answers the agent. + // '_vendor/silent' ignores the abort: the runtime never answers for it. if (method !== '_vendor/silent') { context.signal.addEventListener('abort', () => method === '_vendor/plan' @@ -268,15 +269,51 @@ describe('ACP caller-owned waits', () => { const pending = runtime.prompt([...prompt]) const question = agent.request('question', '_vendor/question', {}) const plan = agent.request('plan', '_vendor/plan', {}) - const silent = agent.request('silent', '_vendor/silent', {}) + void agent.request('silent', '_vendor/silent', {}) await tick() await runtime.cancel() expect(await question).toMatchObject({ error: { code: -32800 } }) expect(await plan).toMatchObject({ result: { outcome: 'abandoned' } }) - expect(await silent).toMatchObject({ error: { code: -32800 } }) await pending await tick() expect(agent.frames.filter((frame) => frame.id === 'plan')).toHaveLength(1) + expect(agent.frames.some((frame) => frame.id === 'silent')).toBe(false) + }) + + it('keeps a handler answer that finishes saving after the cancel', async () => { + // A real I/O hop, the shape of a journal write the handler commits before replying. + const save = (): Promise => readFile(import.meta.filename).then(() => undefined) + const userAnswer = deferred() + const { agent, runtime } = fixture({ + onRequest: (method, _params, context) => + new Promise((resolve) => { + if (method === '_vendor/plan') { + // The user already approved; the save started before the stop and replies after it. + void userAnswer.promise.then(save).then(() => resolve({ outcome: 'approved' })) + } else { + context.signal.addEventListener( + 'abort', + () => void save().then(() => resolve({ outcome: 'abandoned' })) + ) + } + }) + }) + agent.on('session/prompt', (frame) => + agent.on('session/cancel', () => agent.reply(frame, { stopReason: 'cancelled' })) + ) + await runtime.start(startOptions) + const pending = runtime.prompt([...prompt]) + const plan = agent.request('plan', '_vendor/plan', {}) + const question = agent.request('question', '_vendor/question', {}) + await tick() + userAnswer.resolve() + await runtime.cancel() + expect(await plan).toMatchObject({ result: { outcome: 'approved' } }) + expect(await question).toMatchObject({ result: { outcome: 'abandoned' } }) + await pending + await tick() + expect(agent.frames.filter((frame) => frame.id === 'plan')).toHaveLength(1) + expect(agent.frames.filter((frame) => frame.id === 'question')).toHaveLength(1) }) it('settles requests when the process owner closes on exit even if stdout stays open', async () => { diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 718fd2c716e4..1c668fd49bb9 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -52,8 +52,9 @@ export type AcpSessionRuntimeOptions = { peer?: AcpPeerOptions cancelTimeoutMs?: number onPermission?: AcpPermissionHandler - /** Agent requests other than permissions. When `context.signal` aborts on cancel, answer with - * the agent's own cancelled reply or throw right away; a handler still silent gets -32800. */ + /** Agent requests other than permissions. The handler owns its request: once `context.signal` + * aborts, send the agent's own cancelled reply, finish an answer already in progress, or throw + * (-32800). The runtime never answers for it; a request left unanswered ends at `close()`. */ onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown /** Agent notifications other than `session/update` (protocol extensions), delivered * synchronously in arrival order with the `subscribe` events. */ From 1f4cab0b927d9c303f65eaccbadd01939eef1c3d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:07:42 -0700 Subject: [PATCH 132/269] End a turn another writer settled the way the provider's end does A person's Stop settled the open turn's row without a word to the assembler. The assembler then forgot the turn: its running tools and pending prompts were never settled, the provider's own end and withdrawal were dropped, and the turn's text streams stayed counted against the open budget for the life of the process. Text the provider kept streaming afterwards could land as a message outside the stopped turn. - The open turn the journal shows settled ends first, as one transition, through the same settlement the provider's turn.end plans; its streams stop and their keys drop later text until that turn's end or the next turn opens. - turn.end and request.withdrawn are admitted for a turn or request the journal holds; their settlement writes nothing for rows already settled. - The budget's re-check frees streams whose turn settled. - A settled tool keeps its terminal body against any differing write. - Session-end settlement of lost background work uses the journal's own lostLiveWorkJournalBody instead of a copy. - The rig's window elapses before every read, and restart swaps and disposes the old assembler. --- .../journal-subagent-liveness.ts | 15 +- .../provider-timeline-assembler-items.test.ts | 50 ++++ ...rovider-timeline-assembler-refusal.test.ts | 4 +- ...rovider-timeline-assembler-restart.test.ts | 31 +++ ...rovider-timeline-assembler-session.test.ts | 6 +- .../provider-timeline-assembler-stop.test.ts | 231 ++++++++++++++++++ ...rovider-timeline-assembler-test-support.ts | 53 ++-- .../provider-timeline-assembler-turns.test.ts | 6 +- .../provider-timeline-assembler.ts | 29 ++- .../provider-timeline-background-tasks.ts | 37 +-- .../provider-timeline-budget.ts | 12 +- .../provider-timeline-context.ts | 4 +- .../provider-timeline-decision.ts | 15 +- .../provider-timeline-event.ts | 18 +- .../provider-timeline-item-decisions.ts | 33 ++- .../provider-timeline-rows.ts | 19 +- .../provider-timeline-settlement.ts | 21 +- .../provider-timeline-state.ts | 15 +- .../provider-timeline-text-events.ts | 11 +- .../provider-timeline-text-streams.ts | 106 ++++++-- .../provider-timeline-transition-layout.ts | 21 +- .../provider-timeline-turn-decisions.ts | 72 ++++-- 22 files changed, 638 insertions(+), 171 deletions(-) create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts index ec81136688e3..ea4cebab1504 100644 --- a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts @@ -52,8 +52,8 @@ export function staleSubagentRosterRevisions( ): JournalSubagentLivenessRevision[] { const revisions: JournalSubagentLivenessRevision[] = [] for (const item of items) { - const body = item.body - if (body.kind !== 'message' || !body.blocks.some(hasStaleLiveWork)) { + const body = lostLiveWorkJournalBody(item.body) + if (!body) { continue } // A key that will not parse cannot be re-addressed, and appending under a @@ -62,11 +62,20 @@ export function staleSubagentRosterRevisions( if (!identity || agentJournalItemKey(identity) !== item.itemId) { continue } - revisions.push({ identity, body: { ...body, blocks: settleBlocks(body.blocks) } }) + revisions.push({ identity, body }) } return revisions } +/** The row once the host lost the session running its live work: every working child and + * in-flight background task `unverifiable`, plain-text twins restated. Null when none is live. */ +export function lostLiveWorkJournalBody(body: AgentJournalItemBody): AgentJournalItemBody | null { + if (body.kind !== 'message' || !body.blocks.some(hasStaleLiveWork)) { + return null + } + return { ...body, blocks: settleBlocks(body.blocks) } +} + function hasStaleLiveWork(block: NativeChatBlock): boolean { return hasWorkingChild(block) || hasLiveBackgroundTask(block) } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts index 7d6a12178a11..2bebb9ee5b14 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts @@ -101,6 +101,46 @@ describe('provider timeline items', () => { }) }) + it('keeps a settled tool terminal body against a later update carrying another', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) + rig.assembler.apply({ + type: 'item.close', + item: 'call-a', + body: { ...runningTool('read'), state: 'failed' } + }) + const update = rig.assembler.apply({ + type: 'item.update', + item: 'call-a', + body: { ...runningTool('read'), state: 'completed' } + }) + expect(update.dropped).toBe('item-settled') + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'failed' + }) + }) + + it("keeps the sweep's verdict on a tool against the next child's update", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) + const next = await rig.restart() + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'failed' + }) + const update = next.apply({ + type: 'item.update', + item: 'call-a', + body: { ...runningTool('read'), state: 'completed' }, + join: { turn: 'turn-1' } + }) + expect(update.dropped).toBe('item-settled') + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'failed' + }) + }) + it('reopens a settled item under the same row', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) @@ -209,6 +249,16 @@ describe('provider timeline text streams', () => { expect(reply?.revision).toBe(single?.revision) }) + it('writes every delta of a window once it elapses', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + const delta = { type: 'text.delta', item: { stream: 'reply' }, channel: 'assistant' } as const + rig.assembler.apply({ ...delta, text: 'Hel' }) + rig.assembler.apply({ ...delta, text: 'lo' }) + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => messageText(row.body))).toEqual(['Hello']) + }) + it('settles with the provider final text, and keys a named message by its id', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts index 2580926b30a2..f9dbff3efc54 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts @@ -30,7 +30,9 @@ describe('a refused event changes nothing and its retry lands it once', () => { expect(assembler.apply(end)).toEqual({ admission: { accepted: true } }) expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) expect(await rig.turn('t1')).toMatchObject({ state: 'completed', completedAt: 2_000 }) - expect(assembler.apply(end).dropped).toBe('turn-unknown') + // A repeated end is admitted and writes nothing: the row keeps its first end. + expect(assembler.apply(end)).toEqual({ admission: { accepted: true } }) + expect(await rig.turn('t1')).toMatchObject({ state: 'completed', completedAt: 2_000 }) }) it('keeps observed text when the close that would write it is refused', async () => { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts index 9772b13fc128..a61ae858b26f 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts @@ -108,6 +108,37 @@ describe('an adopted session', () => { }) expect(messageText((await rig.row(providerItemId('item', 'a1')))?.body)).toBe('Looking') }) + + // Temporary limit, for the adoption work to lift: the sweep settled the cut turn as the dead + // child's work, so the re-run cannot finish it. + it('leaves the turn a crash cut as the sweep settled it when the adoption re-runs', async () => { + const rig = await openProviderTimelineRig() + const reply: ProviderTimelineEvent[] = [ + { + type: 'text.delta', + item: { id: 'a2' }, + channel: 'assistant', + text: 'Done', + join: { turn: 'p1' } + }, + { type: 'text.close', item: { id: 'a2' }, join: { turn: 'p1' } } + ] + const full = [...history.slice(0, -1), ...reply, ...history.slice(-1)] + full.slice(0, 5).forEach((event) => rig.assembler.apply(event)) + await rig.restart() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal, { + generation: 'gen-3' + }) + full.forEach((event) => assembler.apply(event)) + assembler.flush() + await bind() + + expect((await rig.turn('p1'))?.state).toBe('unverifiable') + expect((await rig.row(providerItemId('item', 'call-1')))?.body).toMatchObject({ + state: 'failed' + }) + expect(await rig.row(providerItemId('item', 'a2'))).toBeUndefined() + }) }) describe('a resumed session', () => { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts index fff993850eb1..cbb0441ac9f6 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts @@ -141,7 +141,11 @@ describe('provider timeline requests', () => { rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) rig.assembler.apply({ type: 'request.open', request: 'perm-1', body: approval }) rig.assembler.apply({ type: 'request.withdrawn', request: 'perm-1' }) - expect(rig.assembler.apply({ type: 'request.withdrawn', request: 'perm-1' }).dropped).toBe( + // The journal holds the row it withdrew: a repeat is admitted and finds nothing pending. + expect(rig.assembler.apply({ type: 'request.withdrawn', request: 'perm-1' })).toEqual({ + admission: { accepted: true } + }) + expect(rig.assembler.apply({ type: 'request.withdrawn', request: 'perm-9' }).dropped).toBe( 'request-unknown' ) expect((await rig.row(providerItemId('request', 'perm-1')))?.body).toMatchObject({ diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts new file mode 100644 index 000000000000..7ce1ec3a3f76 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' +import { MAX_PROVIDER_TIMELINE_OPEN_ENTRIES } from './provider-timeline-budget' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + openUnboundProviderTimelineAssembler, + pendingApproval, + providerItemId, + providerTurnItemId, + runningTool, + type ProviderTimelineRig +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +/** A person's Stop, settled by the host on the journal, with no word to the assembler. */ +async function stop(rig: ProviderTimelineRig, turnKey: string, at = 2_000): Promise { + const running = await rig.turn(turnKey) + const identity = parseAgentJournalItemKey(providerTurnItemId(turnKey)) + if (!running || !identity) { + throw new Error('the turn row is written') + } + await rig.journal.appendItem( + identity, + agentJournalTurnBody({ ...running, state: 'interrupted', completedAt: at }), + { fence: 1, turnScope: { kind: 'thread' } } + ) +} + +async function texts(rig: ProviderTimelineRig): Promise { + return (await rig.rows()).flatMap((row) => { + const text = messageText(row.body) + return text === undefined || row.body.kind !== 'message' || row.body.role === 'user' + ? [] + : [[row.turnScope, text]] + }) +} + +describe('a turn another writer settled ends as the provider end would end it', () => { + it("settles the turn's running tool and pending prompt, then takes the provider's own end", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + rig.assembler.apply({ type: 'request.open', request: '7', body: pendingApproval }) + await stop(rig, 't1') + + // The provider's trailing end of the turn it was asked to stop, and its withdrawal. + expect( + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'interrupted' }) + ).toEqual({ admission: { accepted: true } }) + expect(rig.assembler.apply({ type: 'request.withdrawn', request: '7' })).toEqual({ + admission: { accepted: true } + }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'failed' + }) + expect((await rig.row(providerItemId('request', '7')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + // The Stop's row stands. + expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', completedAt: 2_000 }) + expect(rig.assembler.openTurnId).toBeNull() + }) + + it('settles the same work when the provider end lands before the Stop', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + rig.assembler.apply({ type: 'request.open', request: '7', body: pendingApproval }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'interrupted' }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'failed' + }) + expect((await rig.row(providerItemId('request', '7')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', completedAt: 2_100 }) + }) + + it('settles the work even when the next event is one that writes nothing', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + await stop(rig, 't1') + expect(rig.assembler.apply({ type: 'activity', text: 'Thinking' }).dropped).toBe('no-turn') + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'failed' + }) + }) + + it('never fills the open budget with the streams of turns a person stopped', async () => { + const rig = await openProviderTimelineRig() + for (let n = 1; n <= MAX_PROVIDER_TIMELINE_OPEN_ENTRIES + 12; n += 1) { + const turn = `t${n}` + rig.assembler.apply({ type: 'turn.open', turn, at: n * 10 }) + // Named messages with no close, as a provider that never closes its text sends them. + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: `msg-${n}` }, + channel: 'assistant', + text: `reply ${n}`, + join: { turn } + }).admission + ).toEqual({ accepted: true }) + await stop(rig, turn, n * 10 + 5) + rig.assembler.apply({ type: 'turn.end', turn, at: n * 10 + 6, state: 'interrupted' }) + } + expect(messageText((await rig.row(providerItemId('item', 'msg-140')))?.body)).toBe('reply 140') + }) + + it('frees a stream whose turn another writer settled before refusing for the budget', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 1_100 }) + // Streams joined to turn t1, which is not the open one, so no turn end of this run frees them. + for (let n = 1; n < MAX_PROVIDER_TIMELINE_OPEN_ENTRIES; n += 1) { + rig.assembler.apply({ + type: 'text.delta', + item: { id: `old-${n}` }, + channel: 'assistant', + text: 'x', + join: { turn: 't1' } + }) + } + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'now' }, + channel: 'assistant', + text: 'a' + }) + expect( + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }).admission + ).toEqual({ accepted: true }) + }) +}) + +describe('text after a person stopped its turn never lands outside that turn', () => { + it('drops the rest of an anonymous stream', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'a' }, + channel: 'assistant', + text: 'Hel' + }) + await stop(rig, 't1') + const delta = { type: 'text.delta', item: { stream: 'a' }, channel: 'assistant' } as const + expect(rig.assembler.apply({ ...delta, text: 'lo' }).dropped).toBe('turn-settled') + expect(rig.assembler.apply({ ...delta, text: ' world' }).dropped).toBe('turn-settled') + expect(await texts(rig)).toEqual([ + [{ kind: 'turn', turnItemId: providerTurnItemId('t1') }, 'Hel'] + ]) + }) + + it('drops the rest of a named stream whose first write found the turn stopped', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm' }, + channel: 'assistant', + text: 'Hel' + }) + await stop(rig, 't1') + rig.assembler.flush() + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm' }, + channel: 'assistant', + text: 'lo' + }).dropped + ).toBe('turn-settled') + rig.assembler.flush() + expect(await texts(rig)).toEqual([]) + }) + + it('drops a stopped stream admitted before the sink bound once its write found the turn over', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + await stop(rig, 't1') + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + assembler.apply({ + type: 'text.delta', + item: { stream: 'a' }, + channel: 'assistant', + text: 'Hel', + join: { turn: 't1' } + }) + assembler.flush() + await bind() + expect( + assembler.apply({ + type: 'text.delta', + item: { stream: 'a' }, + channel: 'assistant', + text: 'lo', + join: { turn: 't1' } + }).dropped + ).toBe('turn-settled') + expect(await texts(rig)).toEqual([]) + }) + + it('starts a new message for the same stream once the next turn opens', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'a' }, + channel: 'assistant', + text: 'A' + }) + await stop(rig, 't1') + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'a' }, + channel: 'assistant', + text: 'B' + }) + expect(await texts(rig)).toEqual([ + [{ kind: 'turn', turnItemId: providerTurnItemId('t1') }, 'A'], + [{ kind: 'turn', turnItemId: providerTurnItemId('t2') }, 'B'] + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts index 15b5963db5ea..1009ab6aa532 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -186,12 +186,14 @@ export function openUnboundProviderTimelineAssembler( export type ProviderTimelineRig = { journal: AgentSessionJournal + /** The current child's assembler; `restart` replaces it. */ assembler: ProviderTimelineAssembler sink: ProviderTimelineSink /** The same journal's event sink, for a lane that writes it directly. */ eventSink: StructuredAgentSessionEventSink - /** What a restarted host does: the dead-generation sweep settles what the old child left, then - * a new child gets a new assembler in a new generation. */ + /** What a restarted host does: the old child's assembler is gone, the dead-generation sweep + * settles what it left, then a new child gets a new assembler in a new generation, which + * becomes `assembler`. */ restart(overrides?: Partial): Promise /** Another assembler of the same generation on the same journal, for a test that needs its own * sink; the rig's own assembler must then stay unused. */ @@ -241,6 +243,15 @@ export async function openProviderTimelineRig( if (!sink) { throw new Error('the deferred sink offers transitions') } + // The coalescing window elapses before every read, so all text streamed so far is written, + // unless a test overrides `schedule` to drive the window itself. + const windows = new Set<() => void>() + const elapse = () => { + // Only the windows open now: a flush the sink refused schedules another for the next read. + const due = Array.from(windows) + windows.clear() + due.forEach((run) => run()) + } const build = (more: Partial = {}) => createProviderTimelineAssembler({ sink, @@ -248,15 +259,15 @@ export async function openProviderTimelineRig( agent: AGENT, generation: GENERATION, namespace: NAMESPACE, - // Every delta writes at once unless a test drives the window itself. schedule: (run) => { - run() - return () => {} + windows.add(run) + return () => windows.delete(run) }, ...overrides, ...more }) const rows = async () => { + elapse() await deferred.drained() return journal.snapshot().items } @@ -266,25 +277,26 @@ export async function openProviderTimelineRig( return turn ? [turn] : [] }) let generations = 1 - const restart = async (more: Partial = {}) => { - generations += 1 - const generation = more.generation ?? `gen-${generations}` - await deferred.drained() - await settleStaleStructuredAgentSessionState({ - journal, - sessionId: SESSION, - fence: 1, - acquisitionGeneration: generation, - deathEvidence: null - }) - return build({ ...more, generation }) - } - return { + const rig: ProviderTimelineRig = { journal, assembler: build(), sink, eventSink: deferred.sink, - restart, + restart: async (more = {}) => { + generations += 1 + const generation = more.generation ?? `gen-${generations}` + await rows() + rig.assembler.dispose() + await settleStaleStructuredAgentSessionState({ + journal, + sessionId: SESSION, + fence: 1, + acquisitionGeneration: generation, + deathEvidence: null + }) + rig.assembler = build({ ...more, generation }) + return rig.assembler + }, assemble: build, rows, row: async (itemId) => (await rows()).find((item) => item.itemId === itemId), @@ -297,4 +309,5 @@ export async function openProviderTimelineRig( ) } } + return rig } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts index 19a7750b089c..cd31a2c8e1e7 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts @@ -130,7 +130,7 @@ describe('provider timeline turns', () => { expect(await rig.turn('turn-2')).toMatchObject({ state: 'running', startedAt: 2_000 }) }) - it('drops a repeated open, a repeated end, and an end for a turn it never opened', async () => { + it('drops a repeated open and an end for a turn it never opened; a repeated end writes nothing', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) expect(rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_100 }).dropped).toBe( @@ -143,10 +143,10 @@ describe('provider timeline turns', () => { .dropped ).toBe('turn-unknown') rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + // The journal holds the turn: its end is admitted and finds nothing left to settle. expect( rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 3_000, state: 'interrupted' }) - .dropped - ).toBe('turn-unknown') + ).toEqual({ admission: { accepted: true } }) expect(rig.assembler.apply({ type: 'turn.end', at: 3_000, state: 'completed' }).dropped).toBe( 'no-turn' ) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts index 581c9703be00..0bc2da8b7204 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -29,7 +29,7 @@ import { import type { ProviderTimelineEvent } from './provider-timeline-event' import { createLegacyProviderTimelineIdentityScheme } from './provider-timeline-identity' import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' -import { ProviderTimelineRows } from './provider-timeline-rows' +import { ProviderTimelineRows, providerTimelineTurnRowState } from './provider-timeline-rows' import { ProviderTimelineState } from './provider-timeline-state' import { applyProviderTimelineTextClose, @@ -52,6 +52,7 @@ export type ProviderTimelineAssembler = { readonly openTurnId: string | null /** Writes the text the coalescing window holds. */ flush(): void + /** Drops the text the window holds: apply `session.ended` first, which writes it. */ dispose(): void } @@ -105,7 +106,7 @@ export function createProviderTimelineAssembler( }) const admits = (hold: ProviderTimelineHold, journal: ProviderTimelineTextHost['journal']) => - providerTimelineBudgetAdmits({ hold, state, streams: streams.open, journal }) + providerTimelineBudgetAdmits({ hold, state, streams, journal }) const applyDecided = ( event: ProviderTimelineDecidedEvent, @@ -129,16 +130,34 @@ export function createProviderTimelineAssembler( decision.commit?.(state) serials.commit() }) - if (event.type === 'turn.open' || event.type === 'turn.end' || event.type === 'session.ended') { + if ( + event.type === 'turn.open' || + event.type === 'turn.end' || + event.type === 'turn.settled' || + event.type === 'session.ended' + ) { plan.onAdmitted(() => deps.sink.setActivity?.(null)) } return { admission: plan.submit(deps.sink) } } + /** The open turn another writer settled (a person's Stop) ends here first, as one transition; + * refused, the event that found it is refused with it and its retry finds it again. */ + const endSettledTurn = ( + journal: NonNullable + ): ProviderTimelineApplyResult | null => { + const open = state.open + if (state.ended || !open || providerTimelineTurnRowState(journal, open.itemId) !== 'settled') { + return null + } + return applyDecided({ type: 'turn.settled', turn: open }, journal) + } + const apply = (event: ProviderTimelineEvent): ProviderTimelineApplyResult => { const journal = deps.sink.journalItems() - if (journal) { - state.reconcile(journal) + const settled = journal ? endSettledTurn(journal) : null + if (settled && !settled.admission.accepted) { + return settled } switch (event.type) { case 'text.delta': diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts index 3b4d004160fd..c8522462245c 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts @@ -4,44 +4,9 @@ // that turn; only its own updates, or the session's end, change its state. import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' -import { - backgroundTaskFallbackText, - canReplaceBackgroundTaskState, - isSettledBackgroundTaskState -} from '../../../shared/native-chat-background-task-row' +import { canReplaceBackgroundTaskState } from '../../../shared/native-chat-background-task-row' import { isBackgroundTaskBlock } from '../../../shared/native-chat-types' -/** The row once the host lost the session that ran its tasks: every task still in flight is - * `unverifiable` (nothing says it exited), with its plain-text twin restated to match. Null when - * no task in it is in flight. */ -export function lostProviderTimelineBackgroundTasks( - body: AgentJournalItemBody -): AgentJournalItemBody | null { - if (body.kind !== 'message') { - return null - } - const restated = new Map() - const blocks = body.blocks.map((block) => { - if (!isBackgroundTaskBlock(block) || isSettledBackgroundTaskState(block.state)) { - return block - } - const lost = { ...block, state: 'unverifiable' as const } - restated.set(backgroundTaskFallbackText(block), backgroundTaskFallbackText(lost)) - return lost - }) - if (restated.size === 0) { - return null - } - return { - ...body, - blocks: blocks.map((block) => - block.type === 'text' && restated.has(block.text) - ? { ...block, text: restated.get(block.text) ?? block.text } - : block - ) - } -} - /** Whether `next` would move a task `held` already settled back to a state it may not take. */ export function relightsProviderTimelineBackgroundTask( held: AgentJournalItemBody | null, diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts index a097bf689d69..3422ad01f6e3 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts @@ -2,13 +2,14 @@ // and open text streams. The open set is the state's open items plus the open streams, one entry // per item (an item and its stream share its row id) and one per request key whatever its // incarnation. Before refusing, each entry is checked against the journal by key, so an answer a -// client gave or a row another writer settled frees its room. Past the budget an event is refused +// client gave, a row another writer settled, or a stream whose turn settled frees its room. Past the budget an event is refused // as `failed`, which ends the session the way a failed journal write does, and the journal-derived // dead-generation settlement closes whatever it left open. import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import type { ProviderTimelineState } from './provider-timeline-state' +import type { ProviderTimelineTextStreams } from './provider-timeline-text-streams' export const MAX_PROVIDER_TIMELINE_OPEN_ENTRIES = 128 export const MAX_PROVIDER_TIMELINE_OPEN_BYTES = 1024 * 1024 @@ -23,13 +24,13 @@ export type ProviderTimelineHold = { key: string; bytes: number } export function providerTimelineBudgetAdmits(input: { hold: ProviderTimelineHold state: ProviderTimelineState - streams: readonly ProviderTimelineHold[] + streams: ProviderTimelineTextStreams journal: StructuredAgentSessionTransitionJournal | null }): boolean { if (fits(input)) { return true } - const { journal, state } = input + const { journal, state, streams } = input if (!journal) { return false } @@ -39,13 +40,14 @@ export function providerTimelineBudgetAdmits(input: { state.items.delete(key) } } + streams.stopSettled(journal) return fits(input) } function fits(input: { hold: ProviderTimelineHold state: ProviderTimelineState - streams: readonly ProviderTimelineHold[] + streams: ProviderTimelineTextStreams }): boolean { const open = new Map() for (const [key, entry] of input.state.items) { @@ -53,7 +55,7 @@ function fits(input: { open.set(key, entry.bytes) } } - for (const stream of input.streams) { + for (const stream of input.streams.open) { open.set(stream.key, Math.max(open.get(stream.key) ?? 0, stream.bytes)) } open.set(input.hold.key, Math.max(open.get(input.hold.key) ?? 0, input.hold.bytes)) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts index e65c134ad888..d321dfbe7d8c 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts @@ -27,8 +27,8 @@ export function providerTimelineSettlementId( return `provider-timeline:${context.sessionId}:${context.generation}:${serial}:${what}` } -/** The turn a new row joins: the one the provider names (subagent work joins the open turn while - * keeping its own turn in its identity), else the open one, else none. */ +/** The turn a new row joins: the one the provider names, else the open one, else none. Subagent + * work on a thread of its own joins the open turn whatever turn it names. */ export function providerTimelinePlacement( context: ProviderTimelineContext, state: ProviderTimelineState, diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts index bd1274934676..884f735b50fe 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -22,6 +22,7 @@ import { decideRequest, decideWithdrawal } from './provider-timeline-item-decisions' +import type { ProviderTimelineTurnRef } from './provider-timeline-rows' import type { ProviderTimelineState } from './provider-timeline-state' import { decideContextUsage, @@ -29,7 +30,8 @@ import { decideInput, decideSessionEnd, decideTurnEnd, - decideTurnOpen + decideTurnOpen, + decideTurnSettled } from './provider-timeline-turn-decisions' /** Why an event wrote nothing. Each is a grammar rule the adapter broke or a fact already held. */ @@ -45,10 +47,11 @@ export type ProviderTimelineDropRule = | 'stream-unknown' | 'stream-mismatch' -export type ProviderTimelineDecidedEvent = Exclude< - ProviderTimelineEvent, - { type: 'text.delta' | 'text.close' | 'activity' } -> +export type ProviderTimelineDecidedEvent = + | Exclude + /** The assembler's own: the journal shows the open turn settled by another writer (a person's + * Stop), so it ends here exactly as the provider's `turn.end` would end it. */ + | { type: 'turn.settled'; turn: ProviderTimelineTurnRef } type Journal = StructuredAgentSessionTransitionJournal @@ -104,6 +107,8 @@ export function decideProviderTimelineEvent( return decideTurnOpen(input, event) case 'turn.end': return decideTurnEnd(input, event) + case 'turn.settled': + return decideTurnSettled(event) case 'item.open': case 'item.update': case 'item.close': diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts index a745709210cd..b578088d8ef2 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -16,11 +16,16 @@ // before that work's items. // - `join.turn` attaches a row to the turn the provider says it belongs to, open or not. // - A turn settles once, with only the provider's verdict. A row the journal already holds is -// never written back to an earlier state: a settled turn stays settled, a settled tool never -// runs again, an answered request stays answered. +// never written back to an earlier state: a settled turn stays settled, a settled tool keeps its +// terminal body, an answered request stays answered. +// - A turn another writer settled (a person's Stop) ends here as the provider's `turn.end` would +// end it: its running work settles, and text still streaming into it is dropped until the +// provider's end of that turn or the next `turn.open`. // - Saved history the provider replays goes only into an empty journal (an adopted session), as // ordinary events with the provider's own ids, so re-running an interrupted adoption writes the -// same rows again. +// same rows again. Limit, until the adoption work lifts it: a crash that cut the first run +// inside a turn leaves that turn as the restart's sweep settled it (`unverifiable`, its running +// tools `failed`), and the re-run writes nothing more into it. // - An event the sink refused changed nothing. Re-apply the same event to retry it (after // `backpressure`); `failed` and `closed` are final. // - A provider item is (thread, id): the same id on another thread is another item. Text and @@ -87,7 +92,8 @@ export type ProviderTimelineEvent = } /** A turn began. `turn` is the provider's turn id when it has one; the assembler mints one otherwise. */ | { type: 'turn.open'; turn?: string; at: number } - /** The provider ended a turn. Absent `turn` means the open turn. */ + /** The provider ended a turn. Absent `turn` means the open turn. A turn already over (another + * writer's Stop, a newer turn) takes it too: what it left open settles, its row stays. */ | { type: 'turn.end' turn?: string @@ -103,7 +109,7 @@ export type ProviderTimelineEvent = * started it, which closes as usual: no turn's end settles that row; its own updates do, and the * session's end leaves one still in flight `unverifiable`. */ | ({ type: 'item.open'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) - /** The item's whole current body. Content may be replaced; a settled tool never runs again. */ + /** The item's whole current body. Content may be replaced; a settled tool keeps its terminal body. */ | ({ type: 'item.update'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) /** The item's whole terminal body; it replaces any text streamed into the same item. */ | ({ type: 'item.close'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) @@ -121,7 +127,7 @@ export type ProviderTimelineEvent = /** The provider asked the user something and waits on the answer. */ | ({ type: 'request.open'; request: string; body: ProviderTimelineRequestBody } & Produced & Joined) - /** The provider stopped waiting for an answer it never got. */ + /** The provider stopped waiting for an answer it never got; one already settled stays settled. */ | { type: 'request.withdrawn'; request: string } /** What the provider said about its context window: for `join.turn`, else the open turn, else the last. */ | ({ type: 'context.usage'; usage: AgentSessionContextUsage } & Joined) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts index e3d9ff8ec461..db7ee10131b6 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts @@ -1,5 +1,6 @@ // What item, request and frame events do: admitted on the state, written against the journal. +import { isDeepStrictEqual } from 'node:util' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemBody @@ -38,9 +39,9 @@ function pendingPrompt(body: AgentJournalItemBody | null): body is ProviderTimel ) } -/** Whether the row the journal holds refuses this write: a settled tool never runs again and keeps - * its first terminal body, a settled background task is never relit, and a turn that is over - * takes no work that waits on a settlement it already ran. */ +/** Whether the row the journal holds refuses this write: a settled tool keeps its first terminal + * body (whoever settled it, the sweep included), a settled background task is never relit, and a + * turn that is over takes no work that waits on a settlement it already ran. */ function refusesItemWrite( journal: Journal, row: ProviderTimelineRowId, @@ -50,7 +51,11 @@ function refusesItemWrite( ): boolean { const held = journal.item(row.itemId) const turn = held ? turnOf(held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE) : placed - if (settledTool(held?.body ?? null) && (change === 'close' || requiresTerminalSettlement(body))) { + if ( + held && + settledTool(held.body) && + (change === 'close' || !isDeepStrictEqual(held.body, body)) + ) { return true } if (relightsProviderTimelineBackgroundTask(held?.body ?? null, body)) { @@ -172,27 +177,31 @@ export function decideWithdrawal( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { + const { state, journal, context } = input const key = requestKey(event.request) - const opened = input.state.items.get(key) - if (opened?.kind !== 'request') { + const entry = state.items.get(key) + const opened = entry?.kind === 'request' ? entry : null + // One its turn's end already let go is still the journal's newest row under its id. + if (!opened && journal && !context.rows.heldRequest(event.request, journal)) { return { dropped: 'request-unknown' } } return { settle: { what: 'request-withdrawn', - resolve: (journal) => { - // The open ran first and named its row. Only while it is pending: a client's answer that - // landed first stands. - const held = opened.row ? journal.item(opened.row.itemId) : null + resolve: (at) => { + // The open ran first and named its row. Only while it is pending: a client's answer, or + // the settlement of its turn, that landed first stands. + const row = opened ? opened.row : context.rows.heldRequest(event.request, at) + const held = row ? at.item(row.itemId) : null const cancelled = held && pendingPrompt(held.body) ? cancelledJournalPromptBody(held.body) : null - if (!opened.row || !held || !cancelled) { + if (!row || !held || !cancelled) { return [] } return [ { kind: 'item', - identity: opened.row.identity, + identity: row.identity, body: cancelled, turnScope: held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts index 72c892cfbe9f..8ce41039c302 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts @@ -90,12 +90,21 @@ export class ProviderTimelineRows { /** The first incarnation under `key` the journal holds no row for: never one already written. */ nextRequest(key: string, journal: Journal): ProviderTimelineRowId { - for (let incarnation = 1; ; incarnation += 1) { - const row = this.request(key, incarnation) - if (journal.itemBody(row.itemId) === null) { - return row - } + return this.request(key, this.heldIncarnations(key, journal) + 1) + } + + /** The newest incarnation under `key` the journal holds, if any. */ + heldRequest(key: string, journal: Journal): ProviderTimelineRowId | null { + const held = this.heldIncarnations(key, journal) + return held === 0 ? null : this.request(key, held) + } + + private heldIncarnations(key: string, journal: Journal): number { + let held = 0 + while (journal.itemBody(this.request(key, held + 1).itemId) !== null) { + held += 1 } + return held } private request(key: string, incarnation: number): ProviderTimelineRowId { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts index b8fb5f532233..1baa7c54996b 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -18,9 +18,9 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import { lostLiveWorkJournalBody } from '../agent-session-journal/journal-subagent-liveness' import { terminalAgentJournalBody } from '../agent-session-journal/journal-terminal-settlement' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import { lostProviderTimelineBackgroundTasks } from './provider-timeline-background-tasks' import { agentJournalTurnRowReservedBytes, resolveAgentJournalTurnRowWrite @@ -64,12 +64,17 @@ export function endedProviderTimelineTurn( /** Which open rows a settlement covers: one turn's, or every row when the session ends. */ export type ProviderTimelineSettlementScope = { turnItemId: string } | 'session' -/** The settled rows of `scope`, then each listed turn's end while the journal holds it running. */ +/** The turns a settlement ends, and how; absent when another writer already ended the turn. */ +export type ProviderTimelineSettlementEnding = { + turns: readonly { identity: AgentJournalItemIdentity; itemId: string }[] + end: ProviderTimelineTurnEnd +} + +/** The settled rows of `scope`, then each ended turn's row while the journal holds it running. */ export function providerTimelineSettlement( journal: StructuredAgentSessionTransitionJournal, scope: ProviderTimelineSettlementScope, - turns: readonly { identity: AgentJournalItemIdentity; itemId: string }[], - end: ProviderTimelineTurnEnd + ending?: ProviderTimelineSettlementEnding ): JournalLifecycleMutationInput[] { const mutations: JournalLifecycleMutationInput[] = [] journal.visitItemsWithLinkage((itemId, _sequence, body, attribution) => { @@ -77,19 +82,19 @@ export function providerTimelineSettlement( const covered = scope === 'session' || (turnScope.kind === 'turn' && turnScope.turnItemId === scope.turnItemId) - // Background tasks outlive turns; only the session's end leaves them past seeing. + // Background tasks and subagents outlive turns; only the session's end leaves them past seeing. const settled = !covered ? null : (terminalAgentJournalBody(body) ?? - (scope === 'session' ? lostProviderTimelineBackgroundTasks(body) : null)) + (scope === 'session' ? lostLiveWorkJournalBody(body) : null)) const identity = settled ? parseAgentJournalItemKey(itemId) : null if (settled && identity) { // No linkage: a revision keeps the row's own producer. mutations.push({ kind: 'item', identity, body: settled, turnScope }) } }) - for (const turn of turns) { - const ended = endedTurnRow(journal, turn, end) + for (const turn of ending?.turns ?? []) { + const ended = ending ? endedTurnRow(journal, turn, ending.end) : null if (ended) { mutations.push({ kind: 'item', ...ended, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts index d236f2536fab..22d461b8ba87 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -12,11 +12,7 @@ import { } from '../../../shared/agent-session-journal-types' import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import { - providerTimelineTurnRowState, - type ProviderTimelineRowId, - type ProviderTimelineTurnRef -} from './provider-timeline-rows' +import type { ProviderTimelineRowId, ProviderTimelineTurnRef } from './provider-timeline-rows' /** Closed items remembered so a repeat close is dropped; past this the journal decides alone. */ const MAX_CLOSED_ITEMS = 512 @@ -78,13 +74,6 @@ export class ProviderTimelineState { } } - /** Ends the open turn when another writer of the journal settled its row. */ - reconcile(journal: StructuredAgentSessionTransitionJournal): void { - if (this.open && providerTimelineTurnRowState(journal, this.open.itemId) === 'settled') { - this.endTurn(this.open) - } - } - /** Whether the journal shows the work under `key` settled by any writer (a client's answer). * Work whose write is still queued stays open. */ settledInJournal(key: string, journal: StructuredAgentSessionTransitionJournal): boolean { @@ -135,8 +124,8 @@ export class ProviderTimelineState { endTurn(turn: ProviderTimelineTurnRef): void { if (this.open?.itemId === turn.itemId) { this.open = null + this.latest = turn } - this.latest = turn for (const [key, entry] of this.items) { if (entry.turnItemId === turn.itemId) { this.items.delete(key) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts index 0cf3452440bb..ec93367efd48 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts @@ -51,6 +51,9 @@ export function applyProviderTimelineTextDelta( stream = undefined } if (!stream) { + if (streams.stoppedFor(key, event.join)) { + return { admission: ADMITTED, dropped: 'turn-settled' } + } if ('id' in event.item && settledNamedItem(host, key)) { return { admission: ADMITTED, dropped: 'item-settled' } } @@ -94,9 +97,13 @@ export function applyProviderTimelineTextClose( if (state.ended) { return { admission: ADMITTED, dropped: 'session-ended' } } - const stream = streams.get(streams.key(event.item, event.join)) + const key = streams.key(event.item, event.join) + const stream = streams.get(key) if (!stream) { - return { admission: ADMITTED, dropped: 'stream-unknown' } + return { + admission: ADMITTED, + dropped: streams.stoppedFor(key, event.join) ? 'turn-settled' : 'stream-unknown' + } } const plan = new ProviderTimelinePlan() streams.planFlush(plan, stream) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts index da869954907d..eda78997e6d2 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts @@ -7,7 +7,9 @@ // Text owed to the journal is written inside the next event's transition — every other event is // an ordering barrier — or by the coalescer's window, and is marked written once the sink takes it. // Every write first reads the turn its row is in: once any writer settled that turn (a person's -// Stop, the turn's own end), the stream writes nothing more. +// Stop, the turn's own end), the stream writes nothing more. A stream stopped that way keeps its +// key until the turn's boundary, so the provider's trailing deltas drop rather than start a +// message outside the stopped turn. import { AGENT_JOURNAL_THREAD_SCOPE, @@ -61,13 +63,18 @@ export type ProviderTimelineStream = { /** Whether any text was written; a whitespace-only stream completes nothing. */ written: boolean bytes: number - /** A write found its row's turn settled: it writes nothing more. */ - stopped: boolean + /** The settled turn a write found its row in: it writes nothing more. */ + stoppedIn: string | null } +/** Stopped streams remembered until their turn's boundary; past this the oldest is forgotten. */ +const MAX_STOPPED_STREAMS = 128 + export class ProviderTimelineTextStreams { private readonly streams = new Map() private readonly byId = new Map() + /** Keys of streams stopped by their turn's settlement, to that turn's row id. */ + private readonly stopped = new Map() private readonly coalescer constructor( @@ -89,7 +96,7 @@ export class ProviderTimelineTextStreams { /** What the budget counts: one entry per open stream. */ get open(): readonly ProviderTimelineStream[] { - return [...this.streams.values()].filter((stream) => !stream.stopped) + return [...this.streams.values()].filter((stream) => stream.stoppedIn === null) } /** The stream slot `item` names on its thread. */ @@ -100,16 +107,27 @@ export class ProviderTimelineTextStreams { : `stream:${providerTimelineKeyPart(thread ?? '')}:${providerTimelineKeyPart(item.stream)}` } - /** The open stream in `key`; a stopped one is let go, so the next delta starts another. */ + /** The open stream in `key`; a stopped one is let go and its key kept as stopped. */ get(key: string): ProviderTimelineStream | undefined { const stream = this.streams.get(key) - if (stream?.stopped) { - this.forget(stream) + if (stream?.stoppedIn) { + this.stop(stream, stream.stoppedIn) return undefined } return stream } + /** Whether text for `key` would continue a stream its turn's settlement stopped: it names no + * turn, or names that one. */ + stoppedFor(key: string, join: ProviderTimelineJoin | undefined): boolean { + const turn = this.stopped.get(key) + return ( + turn !== undefined && + (join?.turn === undefined || + this.deps.context.rows.turn(providerKey(join.turn)).itemId === turn) + ) + } + /** A new stream, not yet open; `serial` names it (and an anonymous one's row). */ start(input: { item: ProviderTimelineTextItem @@ -137,7 +155,7 @@ export class ProviderTimelineTextStreams { join: input.join, producer: input.producer }), - stopped: false + stoppedIn: null } } @@ -171,6 +189,39 @@ export class ProviderTimelineTextStreams { } } + /** Stops every stream of a turn another writer settled once the event is admitted: their text + * can never land. */ + planStop(plan: ProviderTimelinePlan, turnItemId: string): void { + const stopped = [...this.streams.values()].filter( + (stream) => turnOf(stream.scope) === turnItemId + ) + if (stopped.length > 0) { + plan.onAdmitted(() => stopped.forEach((stream) => this.stop(stream, turnItemId))) + } + } + + /** A turn's boundary: what its settlement stopped no longer holds the key; null is every turn. */ + planBoundary(plan: ProviderTimelinePlan, turnItemId: string | null): void { + plan.onAdmitted(() => { + for (const [key, turn] of this.stopped) { + if (turnItemId === null || turn === turnItemId) { + this.stopped.delete(key) + } + } + }) + } + + /** Before the budget refuses: a stream whose row's turn the journal shows settled holds nothing, + * since none of its text can land. */ + stopSettled(journal: StructuredAgentSessionTransitionJournal): void { + for (const stream of this.open) { + const turn = this.rowTurn(stream, journal) + if (turn !== null && providerTimelineTurnRowState(journal, turn) === 'settled') { + this.stop(stream, turn) + } + } + } + /** Ends the streams `which` selects once the event is admitted; their text was flushed by it. */ planRelease( plan: ProviderTimelinePlan, @@ -200,8 +251,10 @@ export class ProviderTimelineTextStreams { this.coalescer.flushAll() } + /** Drops the window's unwritten text: apply `session.ended` first, which writes it. */ dispose(): void { - this.open.forEach((stream) => this.forget(stream)) + Array.from(this.byId.values()).forEach((stream) => this.forget(stream)) + this.stopped.clear() this.coalescer.dispose() } @@ -248,18 +301,26 @@ export class ProviderTimelineTextStreams { body: AgentJournalItemBody, journal: StructuredAgentSessionTransitionJournal ): ProviderTimelineResolvedWrite | null { - const held = journal.item(stream.row.itemId) - const turn = turnOf(held ? (held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE) : stream.scope) - if ( - stream.stopped || - (turn !== null && providerTimelineTurnRowState(journal, turn) === 'settled') - ) { - stream.stopped = true + if (stream.stoppedIn !== null) { + return null + } + const turn = this.rowTurn(stream, journal) + if (turn !== null && providerTimelineTurnRowState(journal, turn) === 'settled') { + stream.stoppedIn = turn return null } return { identity: stream.row.identity, body } } + /** The turn the stream's row is in: the journal's for a written row, else where it would go. */ + private rowTurn( + stream: ProviderTimelineStream, + journal: StructuredAgentSessionTransitionJournal + ): string | null { + const held = journal.item(stream.row.itemId) + return turnOf(held ? (held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE) : stream.scope) + } + private message(stream: ProviderTimelineStream, text: string): AgentJournalItemBody { return { kind: 'message', @@ -268,6 +329,19 @@ export class ProviderTimelineTextStreams { } } + private stop(stream: ProviderTimelineStream, turnItemId: string): void { + stream.stoppedIn = turnItemId + this.forget(stream) + this.stopped.delete(stream.key) + this.stopped.set(stream.key, turnItemId) + for (const [oldest] of [...this.stopped].slice( + 0, + Math.max(0, this.stopped.size - MAX_STOPPED_STREAMS) + )) { + this.stopped.delete(oldest) + } + } + private forget(stream: ProviderTimelineStream): void { this.coalescer.forget(stream.id) if (this.streams.get(stream.key) === stream) { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts index a51cc9064dac..e497b5a66df1 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts @@ -18,7 +18,9 @@ import type { ProviderTimelineTextStreams } from './provider-timeline-text-strea const SETTLEMENT_RESERVED_BYTES = 64 * 1024 /** Text owed ahead of the event lands first; a row-writing event also ends the messages it - * separates: anonymous ones of its producer, every stream of a turn it ends, all on a session end. */ + * separates: anonymous ones of its producer, every stream of a turn it ends, all on a session end. + * A turn another writer settled stops its streams until that turn's boundary: its own end, or + * the next turn's open. */ export function planProviderTimelineBarrier( input: { streams: ProviderTimelineTextStreams @@ -40,11 +42,20 @@ export function planProviderTimelineBarrier( streams.planRelease(plan, () => true) return } - if (event.type === 'turn.end' || event.type === 'turn.open') { + if (event.type === 'turn.end' || event.type === 'turn.open' || event.type === 'turn.settled') { const ending = - event.type === 'turn.end' && event.turn !== undefined - ? context.rows.turn(providerKey(event.turn)).itemId - : state.open?.itemId + event.type === 'turn.settled' + ? event.turn.itemId + : event.type === 'turn.end' && event.turn !== undefined + ? context.rows.turn(providerKey(event.turn)).itemId + : state.open?.itemId + if (event.type === 'turn.settled') { + streams.planStop(plan, event.turn.itemId) + } else if (event.type === 'turn.open') { + streams.planBoundary(plan, null) + } else if (ending !== undefined) { + streams.planBoundary(plan, ending) + } streams.planRelease( plan, (stream) => diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts index f242a7573e7b..9584ef55ad85 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts @@ -31,7 +31,8 @@ import { } from './provider-timeline-rows' import { providerTimelineSettlement, - runningProviderTimelineTurns + runningProviderTimelineTurns, + type ProviderTimelineSettlementEnding } from './provider-timeline-settlement' import type { ProviderTimelineOpenTurn, @@ -80,11 +81,14 @@ export function decideTurnOpen( settle: { what: 'turn-superseded', resolve: (journal) => - providerTimelineSettlement(journal, { turnItemId: open.itemId }, [open], { - state: 'interrupted', - completedAt: event.at, - outcome: 'superseded' - }) + providerTimelineSettlement( + journal, + { turnItemId: open.itemId }, + { + turns: [open], + end: { state: 'interrupted', completedAt: event.at, outcome: 'superseded' } + } + ) } } : {}), @@ -114,26 +118,50 @@ export function decideTurnEnd( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { - const { state } = input + const { state, journal } = input const turn = event.turn === undefined ? state.open : input.context.rows.turn(providerKey(event.turn)) if (!turn) { return { dropped: 'no-turn' } } - // Only the open turn ends here; any other is already over (superseded, or ended by its writer). - if (state.open?.itemId !== turn.itemId) { + // A turn this run opened, or one the journal holds (open, superseded, or ended by another + // writer): its end settles whatever is left, and a settled row is not written again. + const known = + state.open?.itemId === turn.itemId || + state.latest?.itemId === turn.itemId || + !journal || + providerTimelineTurnRowState(journal, turn.itemId) !== 'absent' + if (!known) { return { dropped: 'turn-unknown' } } + return endedTurn(turn, { + turns: [turn], + end: { + state: event.state, + completedAt: event.at, + ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), + ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) + } + }) +} + +/** The open turn another writer settled: its work ends as the provider's end would end it, and + * its row stays as that writer left it. */ +export function decideTurnSettled( + event: Extract +): ProviderTimelineDecision { + return endedTurn(event.turn) +} + +/** The one end of a turn: the journal-derived settlement of its open work, then the state. */ +function endedTurn( + turn: ProviderTimelineTurnRef, + ending?: ProviderTimelineSettlementEnding +): ProviderTimelineDecision { return { settle: { - what: 'turn-end', - resolve: (journal) => - providerTimelineSettlement(journal, { turnItemId: turn.itemId }, [turn], { - state: event.state, - completedAt: event.at, - ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), - ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) - }) + what: ending ? 'turn-end' : 'turn-settled', + resolve: (journal) => providerTimelineSettlement(journal, { turnItemId: turn.itemId }, ending) }, commit: (next) => next.endTurn(turn) } @@ -253,12 +281,10 @@ export function decideSessionEnd( settle: { what: 'session-end', resolve: (journal) => - providerTimelineSettlement( - journal, - 'session', - runningProviderTimelineTurns(journal), - event.verdict - ) + providerTimelineSettlement(journal, 'session', { + turns: runningProviderTimelineTurns(journal), + end: event.verdict + }) }, commit: (next) => next.endSession() } From 8b4b497bc708195117706fb1f9b86049cfc1ccad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:25:47 -0700 Subject: [PATCH 133/269] Adopt ACP load history only into an empty journal, chosen by the translator's creator The translator now takes `adopt` from whoever creates it instead of guessing from the journal (which reads empty before the sink binds). Without adoption, history a provider replays during session/load is dropped except its context usage. With adoption, history becomes ordinary events with provider or position ids and the user's saved messages become `input.history`, so re-running an interrupted adoption lands the same rows. The translator no longer reads the journal: it lives exactly as long as its provider child. Its tool snapshots are bounded by the assembler's open budget, and a small record of each tool's turn keeps a late background-task notice beside the tool that started it. --- src/main/acp/acp-background-task-timeline.ts | 42 +-- src/main/acp/acp-history-adoption.ts | 60 +++++ src/main/acp/acp-journal-turns.ts | 62 ----- src/main/acp/acp-replay-user-messages.ts | 43 --- src/main/acp/acp-session-update.ts | 52 +--- ...-timeline-background-task-evidence.test.ts | 57 +--- ...p-timeline-background-task-results.test.ts | 2 +- .../acp/acp-timeline-background-tasks.test.ts | 94 +++---- src/main/acp/acp-timeline-dialect.test.ts | 1 - .../acp/acp-timeline-fixture.test-support.ts | 17 +- src/main/acp/acp-timeline-generic.test.ts | 28 +- src/main/acp/acp-timeline-joins.test.ts | 106 ++------ src/main/acp/acp-timeline-recordings.test.ts | 65 ++++- src/main/acp/acp-timeline-recovery.test.ts | 64 +---- src/main/acp/acp-timeline-requests.ts | 7 +- src/main/acp/acp-timeline-translator.ts | 253 +++++++++--------- src/main/acp/acp-tool-timeline.ts | 57 ++-- 17 files changed, 409 insertions(+), 601 deletions(-) create mode 100644 src/main/acp/acp-history-adoption.ts delete mode 100644 src/main/acp/acp-journal-turns.ts delete mode 100644 src/main/acp/acp-replay-user-messages.ts diff --git a/src/main/acp/acp-background-task-timeline.ts b/src/main/acp/acp-background-task-timeline.ts index 0156a10c223f..058223ac1520 100644 --- a/src/main/acp/acp-background-task-timeline.ts +++ b/src/main/acp/acp-background-task-timeline.ts @@ -1,10 +1,6 @@ -import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { BoundedMap } from '../../shared/bounded-map' import { backgroundTaskJournalBody } from '../../shared/native-chat-background-task-row' -import { - isBackgroundTaskBlock, - type NativeChatBackgroundTaskBlock -} from '../../shared/native-chat-types' +import type { NativeChatBackgroundTaskBlock } from '../../shared/native-chat-types' import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS @@ -14,9 +10,10 @@ import type { ProviderTimelineJoin } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpBackgroundTaskUpdate } from './acp-dialects/acp-dialect' -import { acpJournalToolTurn } from './acp-journal-turns' -/** Partial provider snapshots only; the assembler owns settlement and durable placement. */ +/** Partial provider snapshots only; the assembler owns settlement and durable placement. A task + * the bound evicted (128 others updated since) loses its earlier label, parent tool and output + * file on its next update; its row keeps the turn it was placed in. */ export class AcpBackgroundTaskTimeline { private readonly snapshots = new BoundedMap({ maxEntries: 128, @@ -24,19 +21,16 @@ export class AcpBackgroundTaskTimeline { sizeOf: (block, key) => Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(block)) }) - constructor( - private readonly journalItems: () => readonly AgentJournalRenderItem[], - private readonly toolTurn: (callId: string) => string | undefined - ) {} + constructor(private readonly toolTurn: (callId: string) => string | undefined) {} translate( updates: AcpBackgroundTaskUpdate[], join: ProviderTimelineJoin, - replay = false + history = false ): ProviderTimelineEvent[] { return updates.map((update) => { const { fallbackLabel, ...fields } = update - const previous = this.snapshots.get(update.taskId) ?? this.persisted(update.taskId) + const previous = this.snapshots.get(update.taskId) const block: NativeChatBackgroundTaskBlock = { type: 'background-task', kind: 'unknown', @@ -45,7 +39,7 @@ export class AcpBackgroundTaskTimeline { label: update.label ?? previous?.label ?? fallbackLabel ?? update.taskId, // A historical launch proves no current liveness; explicit outcomes still settle it. state: - replay && ['working', 'monitoring', 'waiting'].includes(update.state) + history && ['working', 'monitoring', 'waiting'].includes(update.state) ? 'unverifiable' : update.state } @@ -56,10 +50,7 @@ export class AcpBackgroundTaskTimeline { } } this.snapshots.set(block.taskId, block) - const turn = block.parentToolUseId - ? (this.toolTurn(block.parentToolUseId) ?? - acpJournalToolTurn(this.journalItems(), block.parentToolUseId)) - : undefined + const turn = block.parentToolUseId ? this.toolTurn(block.parentToolUseId) : undefined return { type: 'item.update', item: `background-task:${block.taskId}`, @@ -68,19 +59,4 @@ export class AcpBackgroundTaskTimeline { } }) } - - private persisted(taskId: string): NativeChatBackgroundTaskBlock | undefined { - for (const row of this.journalItems()) { - if (row.body.kind !== 'message') { - continue - } - const block = row.body.blocks.find( - (candidate) => isBackgroundTaskBlock(candidate) && candidate.taskId === taskId - ) - if (block && isBackgroundTaskBlock(block)) { - return block - } - } - return undefined - } } diff --git a/src/main/acp/acp-history-adoption.ts b/src/main/acp/acp-history-adoption.ts new file mode 100644 index 000000000000..46c5181c5050 --- /dev/null +++ b/src/main/acp/acp-history-adoption.ts @@ -0,0 +1,60 @@ +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import type { SessionUpdate } from './generated/acp-protocol.generated' + +type UserMessageChunk = Extract + +/** Saved history replayed into an empty journal (an adopted session). Ids come from the provider + * or from the replay's own order, so re-running an interrupted adoption lands the same rows. */ +export class AcpHistoryAdoption { + /** The history turn later frames that name none belong to. */ + turn?: string + private serial = 0 + private user?: { messageId: string | null; text: string } + + constructor(private readonly thread: string) {} + + turnFor(providerTurn: string | undefined): string { + return providerTurn ?? this.turn ?? `history:${this.serial++}` + } + + /** A chunk of the saved user message already buffered. */ + continuesUser(update: UserMessageChunk): boolean { + return this.user !== undefined && this.user.messageId === (update.messageId ?? null) + } + + /** Buffers a saved user message: `input.history` takes its whole body once. */ + observeUser(update: UserMessageChunk): void { + const messageId = update.messageId ?? null + const previous = this.continuesUser(update) ? (this.user?.text ?? '') : '' + const text = update.content.type === 'text' ? update.content.text : '' + this.user = { + messageId, + text: boundInlineText(previous + text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + } + } + + /** The buffered user message, as the opener of `turn`. */ + takeUser(turn: string): ProviderTimelineEvent[] { + const user = this.user + this.user = undefined + if (!user?.text) { + return [] + } + return [ + { + type: 'input.history', + item: `history-user:${JSON.stringify([turn, user.messageId])}`, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: user.text }] }, + join: { thread: this.thread, turn } + } + ] + } + + get holdsUser(): boolean { + return this.user !== undefined + } +} diff --git a/src/main/acp/acp-journal-turns.ts b/src/main/acp/acp-journal-turns.ts deleted file mode 100644 index 6d1b95c71274..000000000000 --- a/src/main/acp/acp-journal-turns.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' -import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' -import { providerTimelineKeyPart } from '../native-chat/agent-session-timeline/provider-timeline-identity' - -/** Read provider ids back from the assembler's existing turn records. */ -export function acpJournalTurnKey(row: AgentJournalRenderItem): string | undefined { - const turn = readAgentJournalTurn(row.body) - if (!turn) { - return undefined - } - if (!turn.turnId.startsWith('p:')) { - return turn.turnId - } - const value = turn.turnId.slice(turn.turnId.lastIndexOf(':') + 1) - try { - return decodeURIComponent(value) - } catch { - return undefined - } -} - -export function acpJournalToolTurn( - rows: readonly AgentJournalRenderItem[], - callId: string -): string | undefined { - const tool = rows.find((row) => row.body.kind === 'tool-call' && row.body.callId === callId) - const turnItemId = tool?.turnScope?.kind === 'turn' ? tool.turnScope.turnItemId : undefined - const turn = rows.find((row) => row.itemId === turnItemId) - return turn && acpJournalTurnKey(turn) -} - -export function acpJournalTurnIsSettled( - rows: readonly AgentJournalRenderItem[], - key: string -): boolean { - return rows.some((row) => { - const turn = readAgentJournalTurn(row.body) - return ( - turn !== null && - turn.state !== 'running' && - (turn.turnId === key || turn.turnId.endsWith(`:${providerTimelineKeyPart(key)}`)) - ) - }) -} - -export function acpJournalTurnHasUser( - rows: readonly AgentJournalRenderItem[], - key: string -): boolean { - const record = rows.find((row) => acpJournalTurnKey(row) === key) - if (!record) { - return false - } - const turn = readAgentJournalTurn(record.body) - return rows.some( - (row) => - row.body.kind === 'message' && - row.body.role === 'user' && - (row.itemId === turn?.userItemId || - (row.turnScope?.kind === 'turn' && row.turnScope.turnItemId === record.itemId)) - ) -} diff --git a/src/main/acp/acp-replay-user-messages.ts b/src/main/acp/acp-replay-user-messages.ts deleted file mode 100644 index 189d2e6aead5..000000000000 --- a/src/main/acp/acp-replay-user-messages.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' -import { - boundInlineText, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' -import type { SessionUpdate } from './generated/acp-protocol.generated' - -/** User text has no grammar stream channel; adopted history needs a bounded whole-body snapshot. */ -export class AcpReplayUserMessages { - private current?: { id: string | null; text: string } - - observe(update: SessionUpdate): { startsMessage: boolean; body?: AgentJournalMessageItem } { - if (update.sessionUpdate !== 'user_message_chunk') { - if ( - [ - 'agent_message_chunk', - 'agent_thought_chunk', - 'tool_call', - 'tool_call_update', - 'plan' - ].includes(update.sessionUpdate) - ) { - this.current = undefined - } - return { startsMessage: false } - } - const id = update.messageId ?? null - const startsMessage = !this.current || this.current.id !== id - const previous = startsMessage ? '' : this.current!.text - if (update.content.type !== 'text') { - return { startsMessage } - } - const text = boundInlineText( - previous + update.content.text, - DEFAULT_JOURNAL_PAYLOAD_LIMITS - ).text - this.current = { id, text } - return { - startsMessage, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } - } - } -} diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index c398c129dcd6..74df1762fa10 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -1,4 +1,3 @@ -import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { boundInlineText, @@ -14,38 +13,26 @@ export function acpSessionUpdate( notification: SessionNotification, turn: string | undefined, at: number, - replay: boolean, - tools: AcpToolTimeline, - dialect: AcpDialect, - backgroundTasks: AcpBackgroundTaskTimeline, - replayUserBody?: AgentJournalMessageItem, - messageKey?: string + context: { + /** Adopted history: background-task starts in it prove no current liveness. */ + history: boolean + tools: AcpToolTimeline + dialect: AcpDialect + backgroundTasks: AcpBackgroundTaskTimeline + messageKey?: string + } ): ProviderTimelineEvent[] { const update = notification.update const join = { join: { thread: notification.sessionId, ...(turn === undefined ? {} : { turn }) } } switch (update.sessionUpdate) { case 'agent_message_chunk': case 'agent_thought_chunk': - if (replay && messageKey && update.content.type === 'text') { - return [ - { - type: 'item.update', - item: messageKey, - body: { - kind: 'message', - role: update.sessionUpdate === 'agent_thought_chunk' ? 'reasoning' : 'assistant', - blocks: [{ type: 'text', text: update.content.text }] - }, - ...join - } - ] - } return update.content.type === 'text' ? [ { type: 'text.delta', - item: messageKey - ? { id: messageKey } + item: context.messageKey + ? { id: context.messageKey } : update.messageId ? { id: `message:${update.messageId}` } : { stream: update.sessionUpdate }, @@ -56,29 +43,18 @@ export function acpSessionUpdate( ] : [{ type: 'provider.frame', frameKind: update.sessionUpdate, payload: update, ...join }] case 'user_message_chunk': - return replay && update.content.type === 'text' - ? [ - { - type: 'item.update', - item: `replay-user:${turn}:${update.messageId ?? ''}`, - body: replayUserBody ?? { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: update.content.text }] - }, - ...join - } - ] - : [] + // A live echo of the send is the send's own row; adopted history comes as `input.history`. + return [] case 'tool_call': case 'tool_call_update': { + const { tools, dialect, backgroundTasks } = context const events = tools.translate(update, dialect, join.join) const tool = events[0] const tasks = tool && 'body' in tool && tool.body?.kind === 'tool-call' ? (dialect.toolBackgroundTasks?.(update, tool.body) ?? []) : [] - return [...events, ...backgroundTasks.translate(tasks, join.join, replay)] + return [...events, ...backgroundTasks.translate(tasks, join.join, context.history)] } case 'plan': return [ diff --git a/src/main/acp/acp-timeline-background-task-evidence.test.ts b/src/main/acp/acp-timeline-background-task-evidence.test.ts index 7b98bcce2a0d..5abadae51958 100644 --- a/src/main/acp/acp-timeline-background-task-evidence.test.ts +++ b/src/main/acp/acp-timeline-background-task-evidence.test.ts @@ -110,12 +110,11 @@ describe('background task outcome evidence', () => { ) }) - it('replays the captured task and queue notices into one completed row beside its launch tool', async () => { + it('joins the captured task and queue notices into one completed row beside its launch tool', async () => { const fixture = await openAcpFixtureRig() const tail = await launch(fixture) const [before] = await taskRows(fixture) expect(before?.block.state).toBe('working') - fixture.restart() await fixture.feed(tail) const [after] = await taskRows(fixture) expect(await taskRows(fixture)).toHaveLength(1) @@ -130,7 +129,7 @@ describe('background task outcome evidence', () => { expect((await taskRows(fixture))[0]?.block.state).toBe('done') }) - it.each(['partial', 'restart', 'eviction'] as const)( + it.each(['partial', 'eviction'] as const)( 'SF2 settles named tasks on a completed kill call after %s', async (boundary) => { const fixture = await openAcpFixtureRig() @@ -144,12 +143,9 @@ describe('background task outcome evidence', () => { rawInput: { task_id: taskId, task_ids: [taskId, 'other-task'] } }) await fixture.rig.rows() - if (boundary === 'restart') { - fixture.restart() - } if (boundary === 'eviction') { - for (let index = 0; index < 70; index += 1) { - // Pressure only the translator cache; the target still goes through the journal. + for (let index = 0; index < 200; index += 1) { + // Pressure only the translator's snapshots: settled ones go first, the running call stays. fixture.lane().notification( 'session/update', { @@ -159,7 +155,7 @@ describe('background task outcome evidence', () => { sessionUpdate: 'tool_call', title: 'Read file', toolCallId: `unrelated-${index}`, - status: 'in_progress', + status: 'completed', name: 'read_file', rawInput: { content: 'x'.repeat(40000) }, rawOutput: 'y'.repeat(40000) @@ -202,7 +198,7 @@ describe('background task outcome evidence', () => { it.each(['launch', 'notification'] as const)( 'SF3 treats replayed %s evidence as unverifiable and accepts a later completion', async (source) => { - const fixture = await openAcpFixtureRig() + const fixture = await openAcpFixtureRig({ adopt: true }) fixture.lane().beginLoad() if (source === 'launch') { tool( @@ -225,7 +221,6 @@ describe('background task outcome evidence', () => { } fixture.finishLoad() expect((await taskRows(fixture))[0]?.block.state).toBe('unverifiable') - fixture.restart() taskNotice(fixture, taskId, false, true) expect((await taskRows(fixture))[0]?.block.state).toBe('done') } @@ -234,7 +229,7 @@ describe('background task outcome evidence', () => { it.each(['notification', 'kill'] as const)( 'SF3 preserves the explicit %s outcome included in adopted history', async (completion) => { - const fixture = await openAcpFixtureRig() + const fixture = await openAcpFixtureRig({ adopt: true }) fixture.lane().beginLoad() tool( fixture, @@ -270,42 +265,4 @@ describe('background task outcome evidence', () => { ) } ) - - it('SF3 adopts a missing task into unfinished journal history without claiming it is live', async () => { - const fixture = await openAcpFixtureRig() - fixture.apply( - fixture.lane().notification( - 'session/update', - { - sessionId: 'session-1', - _meta: { promptId: 'historic-turn' }, - update: { - sessionUpdate: 'tool_call', - title: 'Historical launch', - toolCallId: 'historic-tool', - status: 'in_progress' - } - }, - 1000 - ) - ) - await fixture.rig.rows() - fixture.lane().beginLoad() - tool( - fixture, - { - toolCallId: 'historic-tool', - status: 'completed', - rawOutput: { - type: 'BackgroundTaskStarted', - task_id: taskId, - task_type: 'bash', - command: 'sleep 99' - } - }, - true - ) - fixture.finishLoad() - expect((await taskRows(fixture))[0]?.block.state).toBe('unverifiable') - }) }) diff --git a/src/main/acp/acp-timeline-background-task-results.test.ts b/src/main/acp/acp-timeline-background-task-results.test.ts index 49cba692cd36..5a863b45cdfd 100644 --- a/src/main/acp/acp-timeline-background-task-results.test.ts +++ b/src/main/acp/acp-timeline-background-task-results.test.ts @@ -16,7 +16,7 @@ describe('background task results in adopted history', () => { ] as const)( 'settles replay from a TaskOutput result with status %s and exit code %s', async (status, exitCode, state) => { - const fixture = await openAcpFixtureRig() + const fixture = await openAcpFixtureRig({ adopt: true }) const lane = fixture.lane() lane.beginLoad() for (const [index, rawOutput] of [ diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts index eb1c926f251b..798e96cdaeff 100644 --- a/src/main/acp/acp-timeline-background-tasks.test.ts +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -55,51 +55,43 @@ function completed(snapshot: TaskCompletionEvidence = { exit_code: 0 }) { } describe('Grok background tasks through the shared timeline', () => { - it.each(['end', 'reset'] as const)( - 'renders the recorded task outside turn settlement and marks it unverifiable on session %s after restart', - async (boundary) => { - const fixture = await openAcpFixtureRig() - await fixture.feed(await runningFrames()) - const [before] = await taskRows(fixture) - expect(before?.block).toMatchObject({ - taskId, - kind: 'command', - state: 'working', - parentToolUseId: 'call-1', - outputFile: '/workspace/file-1' - }) - expect(before?.row.turnScope?.kind).toBe('turn') - expect((await fixture.rig.turns()).map((turn) => turn.state)).toEqual([ - 'completed', - 'completed' - ]) - expect( - (await fixture.rig.rows()).find((row) => row.body.kind === 'tool-call')?.body - ).toMatchObject({ state: 'completed' }) - if (!before || before.row.body.kind !== 'message') { - throw new Error('Missing background-task row') - } - const render = deriveNativeChatRowContent(before.row.body.blocks) - expect(render.backgroundTasks).toEqual([before.block]) - expect(render.prose).toEqual([]) - - fixture.restart() - fixture.apply([ - boundary === 'end' - ? { type: 'session.ended', verdict: { state: 'unverifiable' } } - : { type: 'session.reset', namespace: 'replacement-session' } - ]) - const [after] = await taskRows(fixture) - expect(after?.row.itemId).toBe(before.row.itemId) - expect(after?.block.state).toBe('unverifiable') - expect(after?.row.body).toMatchObject({ - blocks: [{ type: 'text', text: backgroundTaskFallbackText(after!.block) }, after!.block] - }) + it('renders the recorded task outside turn settlement and marks it unverifiable when the session ends', async () => { + const fixture = await openAcpFixtureRig() + await fixture.feed(await runningFrames()) + const [before] = await taskRows(fixture) + expect(before?.block).toMatchObject({ + taskId, + kind: 'command', + state: 'working', + parentToolUseId: 'call-1', + outputFile: '/workspace/file-1' + }) + expect(before?.row.turnScope?.kind).toBe('turn') + expect((await fixture.rig.turns()).map((turn) => turn.state)).toEqual([ + 'completed', + 'completed' + ]) + expect( + (await fixture.rig.rows()).find((row) => row.body.kind === 'tool-call')?.body + ).toMatchObject({ state: 'completed' }) + if (!before || before.row.body.kind !== 'message') { + throw new Error('Missing background-task row') } - ) + const render = deriveNativeChatRowContent(before.row.body.blocks) + expect(render.backgroundTasks).toEqual([before.block]) + expect(render.prose).toEqual([]) + + fixture.apply([{ type: 'session.ended', verdict: { state: 'unverifiable' } }]) + const [after] = await taskRows(fixture) + expect(after?.row.itemId).toBe(before.row.itemId) + expect(after?.block.state).toBe('unverifiable') + expect(after?.row.body).toMatchObject({ + blocks: [{ type: 'text', text: backgroundTaskFallbackText(after!.block) }, after!.block] + }) + }) it.each(['x.ai/', '_x.ai/'])( - 'joins %s task notifications and the tool result into one row and keeps its origin after restart', + 'joins %s task notifications and the tool result into one row that keeps its origin', async (prefix) => { const fixture = await openAcpFixtureRig() const frames = await runningFrames() @@ -109,7 +101,6 @@ describe('Grok background tasks through the shared timeline', () => { const [before] = await taskRows(fixture) expect(await taskRows(fixture)).toHaveLength(1) expect(before?.block.label).toBe('Sleep then write bg-marker.txt') - fixture.restart() fixture.apply( fixture .lane() @@ -150,9 +141,10 @@ describe('Grok background tasks through the shared timeline', () => { } ) - it('recovers partial task metadata from the journal after bounded snapshots are evicted', async () => { + it('keeps the row and placement of a task whose snapshot the bound evicted', async () => { const fixture = await openAcpFixtureRig() await fixture.feed(await runningFrames()) + const [before] = await taskRows(fixture) for (let index = 0; index < 130; index += 1) { fixture.apply( fixture @@ -160,19 +152,14 @@ describe('Grok background tasks through the shared timeline', () => { .notification('x.ai/task_backgrounded', started(`task-${index}`), 1500 + index) ) } - await fixture.rig.rows() fixture.apply(fixture.lane().notification('x.ai/task_completed', completed(), 2000)) const original = (await taskRows(fixture)).find((task) => task.block.taskId === taskId) - expect(original?.block).toMatchObject({ - state: 'done', - parentToolUseId: 'call-1', - outputFile: '/workspace/file-1', - kind: 'command' - }) - expect(original && backgroundTaskFallbackText(original.block)).toContain('finished') + expect(original?.row.itemId).toBe(before?.row.itemId) + expect(original?.row.turnScope).toEqual(before?.row.turnScope) + expect(original?.block.state).toBe('done') }) - it('places a first task notice after restart beside its original tool while a different turn is active', async () => { + it('places a first task notice beside its original tool after its turn ended and another began', async () => { const fixture = await openAcpFixtureRig() await fixture.feed((await runningFrames()).slice(0, 5)) fixture.apply( @@ -187,7 +174,6 @@ describe('Grok background tasks through the shared timeline', () => { ) fixture.apply(fixture.lane().promptResult('p1:3', { stopReason: 'end_turn' }, 1300)) const original = (await fixture.rig.rows()).find((row) => row.body.kind === 'tool-call') - fixture.restart() fixture.apply(fixture.lane().openPrompt('next', 1400).events) fixture.apply( fixture.lane().notification( diff --git a/src/main/acp/acp-timeline-dialect.test.ts b/src/main/acp/acp-timeline-dialect.test.ts index 23fd7024ff13..f58ce2618d8c 100644 --- a/src/main/acp/acp-timeline-dialect.test.ts +++ b/src/main/acp/acp-timeline-dialect.test.ts @@ -14,7 +14,6 @@ async function dialectRig() { const rig = await openProviderTimelineRig() const translator = new AcpTimelineTranslator({ sessionId: 'provider-1', - journalItems: () => rig.journal.snapshot().items, dialect: GROK_ACP_DIALECT }) const apply = (events: ProviderTimelineEvent[]) => { diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts index 3e2bdd39afcb..24e94f926a3a 100644 --- a/src/main/acp/acp-timeline-fixture.test-support.ts +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -65,15 +65,17 @@ function fixtureAnswer( } } -/** Captured frames go through translation, assembly and the real serialized journal. */ -export async function openAcpFixtureRig() { +/** Captured frames go through translation, assembly and the real serialized journal. `adopt` is + * the creator's decision that `session/load` history goes into an empty journal. */ +export async function openAcpFixtureRig(options: { adopt?: boolean } = {}) { const rig = await openProviderTimelineRig() let providerSessionId = 'session-1' + let adopt = options.adopt ?? false const translator = () => new AcpTimelineTranslator({ sessionId: providerSessionId, - journalItems: () => rig.journal.snapshot().items, - dialect: GROK_ACP_DIALECT + dialect: GROK_ACP_DIALECT, + adopt }) let lane = translator() const requests = new Map< @@ -90,7 +92,6 @@ export async function openAcpFixtureRig() { const { message, direction } = frame const at = 1000 + index if (direction === 'out' && message.method === 'session/load') { - await rig.rows() lane.beginLoad() } else if (direction === 'out' && message.method === 'session/prompt') { const session = z.object({ sessionId: z.string() }).parse(message.params) @@ -163,8 +164,10 @@ export async function openAcpFixtureRig() { apply, feed, lane: () => lane, - restart: () => { - rig.assembler = rig.restart({ generation: 'gen-2' }) + /** A new provider child: the sweep, a new generation's assembler and a new translator. */ + restart: async (next: { adopt?: boolean } = {}) => { + await rig.restart() + adopt = next.adopt ?? false lane = translator() }, finishLoad: () => apply(lane.finishLoad(2000)) diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts index 2b9ebb51dcd3..a18c67b03b04 100644 --- a/src/main/acp/acp-timeline-generic.test.ts +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -11,12 +11,9 @@ import { AcpTimelineTranslator, acpTurnEnd } from './acp-timeline-translator' afterEach(closeProviderTimelineRigs) -async function genericRig() { +async function genericRig(options: { adopt?: boolean } = {}) { const rig = await openProviderTimelineRig() - const translator = new AcpTimelineTranslator({ - sessionId: 'provider-1', - journalItems: () => rig.journal.snapshot().items - }) + const translator = new AcpTimelineTranslator({ sessionId: 'provider-1', ...options }) const apply = (events: ProviderTimelineEvent[]) => { for (const event of events) { expect(rig.assembler.apply(event).admission.accepted).toBe(true) @@ -220,7 +217,7 @@ describe('generic ACP translation', () => { }) it('adopts generic unmarked load history and never invents a success verdict', async () => { - const { rig, translator, apply, update } = await genericRig() + const { rig, translator, apply, update } = await genericRig({ adopt: true }) translator.beginLoad() update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'User' } }) update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'History' } }) @@ -232,6 +229,23 @@ describe('generic ACP translation', () => { ).toEqual(['user', 'assistant']) }) + it('drops unmarked load history its creator does not adopt, except context usage', () => { + const translator = new AcpTimelineTranslator({ sessionId: 'provider-1' }) + translator.beginLoad() + const events = [ + { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'User' } }, + { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'History' } }, + { sessionUpdate: 'tool_call', toolCallId: 'old-tool', title: 'Read', status: 'completed' }, + { sessionUpdate: 'usage_update', used: 5000, size: 256000 } + ].flatMap((update) => + translator.notification('session/update', { sessionId: 'provider-1', update }, 1100) + ) + expect(events).toEqual([ + expect.objectContaining({ type: 'context.usage', join: { thread: 'provider-1' } }) + ]) + expect(translator.finishLoad(1200)).toEqual([]) + }) + it('requires an explicit journal decision for marked replay and uses typed standard events', async () => { const { rig, translator, apply } = await genericRig() expect( @@ -268,7 +282,7 @@ describe('generic ACP translation', () => { expect((await rig.rows()).some((row) => messageText(row.body) === 'typed')).toBe(true) }) it('adopts streamed user history across multiple turns without replacing earlier users', async () => { - const { rig, translator, apply, update } = await genericRig() + const { rig, translator, apply, update } = await genericRig({ adopt: true }) translator.beginLoad() update({ sessionUpdate: 'user_message_chunk', diff --git a/src/main/acp/acp-timeline-joins.test.ts b/src/main/acp/acp-timeline-joins.test.ts index af14c305f1e3..722ccfb1a8f7 100644 --- a/src/main/acp/acp-timeline-joins.test.ts +++ b/src/main/acp/acp-timeline-joins.test.ts @@ -1,23 +1,20 @@ import { afterEach, describe, expect, it } from 'vitest' -import { createCodexProviderTimelineIdentityScheme } from '../codex/codex-provider-timeline-identity' import { closeProviderTimelineRigs, + GENERATION, messageText, - openProviderTimelineRig + openProviderTimelineRig, + providerItemId } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { AcpTimelineTranslator } from './acp-timeline-translator' afterEach(closeProviderTimelineRigs) -describe('ACP joins through the v3 assembler', () => { +describe('ACP joins through the assembler', () => { it('keeps provider message and tool ids apart across ACP sessions', async () => { const rig = await openProviderTimelineRig() for (const sessionId of ['session-a', 'session-b']) { - const translator = new AcpTimelineTranslator({ - sessionId, - journalItems: () => rig.journal.snapshot().items - }) + const translator = new AcpTimelineTranslator({ sessionId }) for (const update of [ { sessionUpdate: 'agent_message_chunk', @@ -50,85 +47,34 @@ describe('ACP joins through the v3 assembler', () => { ]) }) - it('resumes named text in its existing ordinal row and closes the same joined stream', async () => { - const rig = await openProviderTimelineRig({ - scheme: createCodexProviderTimelineIdentityScheme({ - sessionId: 'session-timeline', - primaryThreadId: () => 'provider-1' - }), - ownThread: () => 'provider-1' - }) - const translator = new AcpTimelineTranslator({ - sessionId: 'provider-1', - journalItems: () => rig.journal.snapshot().items - }) - const apply = (events: ProviderTimelineEvent[]) => { - for (const event of events) { - expect(rig.assembler.apply(event).admission.accepted).toBe(true) - } - } - const chunk = (text: string) => - translator.notification( - 'session/update', - { - sessionId: 'provider-1', - update: { - sessionUpdate: 'agent_message_chunk', - messageId: 'm1', - content: { type: 'text', text } - } - }, - 1100 - ) - apply(translator.openPrompt('send-1', 1000).events) - apply(chunk('Hel')) - const before = (await rig.rows()).find((row) => row.body.kind === 'message')! - rig.assembler = rig.restart({ generation: 'gen-2' }) - const delta = chunk('lo')[0]! - if (delta.type !== 'text.delta') { - throw new Error('Expected a translated text delta') - } - expect(delta.join).toEqual({ thread: 'provider-1', turn: 'prompt:send-1' }) - apply([delta, { type: 'text.close', item: delta.item, join: delta.join }]) - expect(rig.assembler.apply(delta).dropped).toBe('item-settled') - const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') - expect(messages).toHaveLength(1) - expect(messages[0]!.itemId).toBe(before.itemId) - expect(messageText(messages[0]!.body)).toBe('Hello') - }) - - it('lets the assembler recover request incarnations and the open turn after a restart', async () => { + it('keeps a request id a new child reuses apart from the old child', async () => { const rig = await openProviderTimelineRig() - const build = () => - new AcpTimelineTranslator({ - sessionId: 'provider-1', - journalItems: () => rig.journal.snapshot().items - }) const params = { sessionId: 'provider-1', toolCall: { toolCallId: 'call-1', title: 'Run?' }, options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }] } - const translator = build() - for (const event of translator.openPrompt('send-1', 1000).events) { - rig.assembler.apply(event) - } - const event = translator.request('session/request_permission', params, 0).events[0]! - if (event.type !== 'request.open') { - throw new Error('Expected a translated request') - } - expect(event.join).toEqual({ thread: 'provider-1' }) - rig.assembler.apply(event) - rig.assembler.apply({ type: 'request.withdrawn', request: event.request }) - const first = (await rig.rows()).find((row) => row.body.kind === 'approval')! - rig.assembler = rig.restart({ generation: 'gen-2' }) - for (const reopened of build().request('session/request_permission', params, 0).events) { - expect(rig.assembler.apply(reopened).admission.accepted).toBe(true) + const ask = () => { + const translator = new AcpTimelineTranslator({ sessionId: 'provider-1' }) + for (const event of [ + ...translator.openPrompt('send-1', 1000).events, + ...translator.request('session/request_permission', params, 0).events + ]) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } } + ask() + await rig.restart() + ask() + const request = 'session/request_permission:0' const approvals = (await rig.rows()).filter((row) => row.body.kind === 'approval') - expect(approvals).toHaveLength(2) - expect(approvals[1]!.itemId).not.toBe(first.itemId) - expect(approvals[1]!.turnScope).toEqual(first.turnScope) - expect(approvals[1]!.body).toMatchObject({ resolution: { state: 'pending' } }) + expect(approvals.map((row) => row.itemId)).toEqual([ + providerItemId('request', request, { generation: GENERATION }), + providerItemId('request', request, { generation: 'gen-2' }) + ]) + // The sweep cancelled the dead child's pending request; the new child's waits. + expect( + approvals.map((row) => row.body.kind === 'approval' && row.body.resolution.state) + ).toEqual(['cancelled', 'pending']) }) }) diff --git a/src/main/acp/acp-timeline-recordings.test.ts b/src/main/acp/acp-timeline-recordings.test.ts index e3dfe80ef58a..5bcb22d73cb4 100644 --- a/src/main/acp/acp-timeline-recordings.test.ts +++ b/src/main/acp/acp-timeline-recordings.test.ts @@ -1,7 +1,9 @@ import { afterEach, describe, expect, it } from 'vitest' +import { contextTokensFromUsage } from '../../shared/agent-session-context-usage' import { closeProviderTimelineRigs, - messageText + messageText, + providerItemId } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' @@ -71,23 +73,42 @@ describe('recorded ACP traffic through the journal', () => { expect(rows.some((row) => messageText(row.body)?.includes('ok'))).toBe(true) }) - it('suppresses load replay from the existing journal after an assembler restart', async () => { + it('drops load history a journal already holds, except its context usage', async () => { const fixture = await openAcpFixtureRig() const frames = await readAcpFixture('s4-resume') await fixture.feed(frames.filter((frame) => frame.process === 'A')) const before = await fixture.rig.rows() - fixture.restart() + await fixture.restart({ adopt: false }) const loading = frames.filter((frame) => frame.process === 'B') const nextPrompt = loading.findIndex((frame) => frame.message.method === 'session/prompt') await fixture.feed(loading.slice(0, nextPrompt)) + fixture.apply( + fixture.lane().notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { isReplay: true }, + update: { sessionUpdate: 'usage_update', used: 5000, size: 256000 } + }, + 1900 + ) + ) fixture.finishLoad() - expect(await fixture.rig.rows()).toEqual(before) + const after = await fixture.rig.rows() + const items = (rows: typeof before) => rows.filter((row) => row.body.kind !== 'turn') + expect(items(after)).toEqual(items(before)) + const [turn] = await fixture.rig.turns() + expect(turn?.contextUsage?.window?.tokens).toBe(256000) + expect( + turn?.contextUsage?.used?.kind === 'estimate' && + contextTokensFromUsage(turn.contextUsage.used.usage) + ).toBe(5000) await fixture.feed(loading.slice(nextPrompt)) expect(await fixture.rig.turns()).toHaveLength(2) }) it('adopts coalesced load replay into an empty journal, including user and completed tools', async () => { - const fixture = await openAcpFixtureRig() + const fixture = await openAcpFixtureRig({ adopt: true }) const frames = (await readAcpFixture('s4-resume')).filter((frame) => frame.process === 'B') const nextPrompt = frames.findIndex((frame) => frame.message.method === 'session/prompt') await fixture.feed(frames.slice(0, nextPrompt)) @@ -106,6 +127,40 @@ describe('recorded ACP traffic through the journal', () => { expect(await fixture.rig.turns()).toHaveLength(2) }) + it('adopts history as rows with the provider ids, and a re-run of the adoption adds no copy', async () => { + const fixture = await openAcpFixtureRig({ adopt: true }) + const frames = (await readAcpFixture('s4-resume')).filter((frame) => frame.process === 'B') + const load = frames.slice( + 0, + frames.findIndex((frame) => frame.message.method === 'session/prompt') + ) + await fixture.feed(load) + fixture.finishLoad() + const adopted = await fixture.rig.rows() + const user = adopted.find((row) => row.body.kind === 'message' && row.body.role === 'user') + expect(user?.itemId).toBe( + providerItemId('item', `history-user:${JSON.stringify(['prompt:A:3', null])}`, { + thread: 'session-1' + }) + ) + expect(await fixture.rig.turn('prompt:A:3')).toMatchObject({ + userItemId: user?.itemId, + state: 'completed' + }) + expect( + adopted.flatMap((row) => (row.body.kind === 'tool-call' ? [row.itemId] : [])).sort() + ).toEqual( + ['tool:call-1', 'tool:call-2'] + .map((key) => providerItemId('item', key, { thread: 'session-1' })) + .sort() + ) + + await fixture.restart({ adopt: true }) + await fixture.feed(load) + fixture.finishLoad() + expect(await fixture.rig.rows()).toEqual(adopted) + }) + it('answers questions and plan approval with the recorded exact reply shapes', async () => { const fixture = await openAcpFixtureRig() const rows = await fixture.feed(await readAcpFixture('s5-plan-approved')) diff --git a/src/main/acp/acp-timeline-recovery.test.ts b/src/main/acp/acp-timeline-recovery.test.ts index e300d7a3e203..6bc11b594756 100644 --- a/src/main/acp/acp-timeline-recovery.test.ts +++ b/src/main/acp/acp-timeline-recovery.test.ts @@ -1,9 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { contextTokensFromUsage } from '../../shared/agent-session-context-usage' -import { - closeProviderTimelineRigs, - messageText -} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' import { AcpTimelineTranslator } from './acp-timeline-translator' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' @@ -35,59 +32,6 @@ describe('ACP reviewed traffic and recovery', () => { expect(usage?.kind === 'estimate' && contextTokensFromUsage(usage.usage)).toBe(39190) }) - it('adopts missing history into a partial journal after a restart without duplicating partial text', async () => { - const f = await openAcpFixtureRig() - const frames = await readAcpFixture('s4-resume') - const live = frames.filter((frame) => frame.process === 'A') - const firstText = live.findIndex( - (frame) => - frame.message.params && JSON.stringify(frame.message.params).includes('agent_message_chunk') - ) - await f.feed(live.slice(0, firstText + 1)) - const partial = (await f.rig.rows()).find((row) => row.body.kind === 'message') - expect(partial).toBeDefined() - f.restart() - const replay = frames.filter((frame) => frame.process === 'B') - const next = replay.findIndex((frame) => frame.message.method === 'session/prompt') - await f.feed(replay.slice(0, next)) - f.finishLoad() - const rows = await f.rig.rows() - const messages = rows.filter( - (row) => row.body.kind === 'message' && row.body.role === 'assistant' - ) - expect(messages).toHaveLength(2) - expect(messages[0]?.itemId).toBe(partial?.itemId) - expect(messages.map((row) => messageText(row.body))).toEqual([ - "I'll read `notes.txt` and report the second line.", - 'The second line of `notes.txt` is `bananas`.' - ]) - expect(rows.filter((row) => row.body.kind === 'tool-call')).toHaveLength(2) - expect((await f.rig.turns())[0]?.outcome).toBe('success') - }) - - it('re-derives replay adoption after adoption itself was interrupted', async () => { - const f = await openAcpFixtureRig() - const replay = (await readAcpFixture('s4-resume')).filter((frame) => frame.process === 'B') - const next = replay.findIndex((frame) => frame.message.method === 'session/prompt') - await f.feed(replay.slice(0, 4)) - const original = (await f.rig.rows()).find( - (row) => row.body.kind === 'message' && row.body.role === 'assistant' - ) - f.restart() - await f.feed(replay.slice(0, next)) - f.finishLoad() - const rows = await f.rig.rows() - expect( - rows.filter((row) => row.body.kind === 'message' && row.body.role === 'user') - ).toHaveLength(1) - const assistants = rows.filter( - (row) => row.body.kind === 'message' && row.body.role === 'assistant' - ) - expect(assistants).toHaveLength(2) - expect(assistants[0]?.itemId).toBe(original?.itemId) - expect((await f.rig.turns())[0]?.outcome).toBe('success') - }) - it.each([undefined, null])( 'accepts an empty plan request and replies with the valid approval shape (%s)', async (planContent) => { @@ -112,11 +56,7 @@ describe('ACP reviewed traffic and recovery', () => { ) it('degrades tool snapshots under forty large concurrent tools without throwing or losing later updates', () => { - const lane = new AcpTimelineTranslator({ - sessionId: 'session-1', - journalItems: () => [], - dialect: GROK_ACP_DIALECT - }) + const lane = new AcpTimelineTranslator({ sessionId: 'session-1', dialect: GROK_ACP_DIALECT }) const big = 'x"\\'.repeat(12000) for (let index = 0; index < 40; index += 1) { expect(() => diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts index 23bc215e80b7..b60e860d2cda 100644 --- a/src/main/acp/acp-timeline-requests.ts +++ b/src/main/acp/acp-timeline-requests.ts @@ -1,8 +1,6 @@ import { z } from 'zod' -import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect, AcpRequestPresentation } from './acp-dialects/acp-dialect' -import { acpJournalToolTurn } from './acp-journal-turns' import type { AcpToolTimeline } from './acp-tool-timeline' import { AcpRpcError } from './acp-errors' import { RequestPermissionRequestSchema } from './generated/acp-protocol.generated' @@ -55,7 +53,6 @@ export function translateAcpRequest( id: string | number, options: { sessionId: string - journalItems(): readonly AgentJournalRenderItem[] dialect: AcpDialect tools: AcpToolTimeline } @@ -70,9 +67,7 @@ export function translateAcpRequest( : options.dialect.request?.(method, params) const tool = requestToolSchema.safeParse(params) const callId = tool.success ? (tool.data.toolCall?.toolCallId ?? tool.data.toolCallId) : undefined - const turn = callId - ? (options.tools.turn(callId) ?? acpJournalToolTurn(options.journalItems(), callId)) - : undefined + const turn = callId ? options.tools.turn(callId) : undefined const join = { thread: options.sessionId, ...(turn === undefined ? {} : { turn }) } if (!presentation) { return { diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 1a93fe55dcf9..7fabbb48000f 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -1,61 +1,67 @@ import { z } from 'zod' -import type { - AgentJournalMessageItem, - AgentJournalRenderItem -} from '../../shared/agent-session-journal-types' import { BoundedMap } from '../../shared/bounded-map' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' -import { acpNotificationEnvelopeSchema, AcpContextTimeline } from './acp-context-usage' +import { + acpNotificationEnvelopeSchema, + AcpContextTimeline, + acpWindowUsage +} from './acp-context-usage' import { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' import { GENERIC_ACP_DIALECT, type AcpDialect, + type AcpDialectNotification, type AcpRequestPresentation } from './acp-dialects/acp-dialect' -import { acpJournalTurnIsSettled, acpJournalTurnHasUser } from './acp-journal-turns' +import { AcpHistoryAdoption } from './acp-history-adoption' import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' -import { AcpReplayUserMessages } from './acp-replay-user-messages' import type { AcpSessionEvent } from './acp-session-runtime' import { translateAcpRequest } from './acp-timeline-requests' export { acpTurnEnd } from './acp-prompt-turns' import { acpSessionUpdate } from './acp-session-update' import { AcpToolTimeline } from './acp-tool-timeline' import { AcpTurnMessages } from './acp-turn-messages' -import { SessionNotificationSchema, type PromptResponse } from './generated/acp-protocol.generated' +import { + SessionNotificationSchema, + type PromptResponse, + type SessionNotification +} from './generated/acp-protocol.generated' const requestSessionSchema = z.object({ sessionId: z.string() }) -type LoadReplay = { - adopt: boolean - turn?: string - serial: number - users: AcpReplayUserMessages - pendingUser?: AgentJournalMessageItem -} +const SUBSTANTIVE_UPDATES = [ + 'user_message_chunk', + 'agent_message_chunk', + 'agent_thought_chunk', + 'tool_call', + 'tool_call_update', + 'plan' +] + export type AcpTimelineTranslatorOptions = { sessionId: string - /** Committed journal after the host drains earlier writes before session/load. */ - journalItems(): readonly AgentJournalRenderItem[] dialect?: AcpDialect + /** The creator's decision, made from the journal before spawn: translate `session/load` history + * because the journal is empty (an adopted session). Otherwise history stays out of the + * timeline and only its context usage reads on. */ + adopt?: boolean } -/** Consumes each frame once; the host retries the returned grammar events. */ +/** Consumes each frame once; the host retries the returned grammar events. Lives exactly as long + * as its provider child, so it never reads the journal. */ export class AcpTimelineTranslator { private readonly dialect: AcpDialect private readonly prompts: AcpPromptTurns - private readonly tools: AcpToolTimeline + private readonly tools = new AcpToolTimeline() private readonly backgroundTasks: AcpBackgroundTaskTimeline private readonly messages = new AcpTurnMessages() private readonly started = new BoundedMap({ maxEntries: 128 }) - private replay?: LoadReplay + private load?: { adoption?: AcpHistoryAdoption } private readonly context = new AcpContextTimeline() private activeTurn?: string constructor(private readonly options: AcpTimelineTranslatorOptions) { this.dialect = options.dialect ?? GENERIC_ACP_DIALECT - this.tools = new AcpToolTimeline(options.journalItems) - this.backgroundTasks = new AcpBackgroundTaskTimeline(options.journalItems, (callId) => - this.tools.turn(callId) - ) + this.backgroundTasks = new AcpBackgroundTaskTimeline((callId) => this.tools.turn(callId)) this.prompts = new AcpPromptTurns( options.sessionId, this.dialect.injectedPromptIdentity === true @@ -67,7 +73,7 @@ export class AcpTimelineTranslator { clientMessageId: string, at: number ): { promptId: string; events: ProviderTimelineEvent[] } { - if (this.replay) { + if (this.load) { throw new Error('ACP prompt overlaps a prompt or load') } return this.prompts.open(clientMessageId, at) @@ -106,24 +112,24 @@ export class AcpTimelineTranslator { } beginLoad(): void { - if (this.prompts.current || this.replay) { + if (this.prompts.current || this.load) { throw new Error('ACP load overlaps a prompt or load') } - this.replay = { - adopt: this.options.journalItems().length === 0, - serial: 0, - users: new AcpReplayUserMessages() - } + this.load = this.options.adopt + ? { adoption: new AcpHistoryAdoption(this.options.sessionId) } + : {} } finishLoad(at: number): ProviderTimelineEvent[] { - const turn = this.replay?.turn - this.replay = undefined - if (!turn) { + const adoption = this.load?.adoption + this.load = undefined + if (!adoption) { return [] } - this.end(turn) - return [{ type: 'turn.end', turn, at, state: 'completed' }] + const events = adoption.holdsUser + ? this.openHistory(adoption, adoption.turnFor(undefined), at) + : [] + return [...events, ...this.endHistory(adoption, at)] } sessionEvent(event: AcpSessionEvent, at: number): ProviderTimelineEvent[] { @@ -148,85 +154,41 @@ export class AcpTimelineTranslator { return [] } const envelope = acpNotificationEnvelopeSchema.safeParse(params) + const standard = + method === 'session/update' ? SessionNotificationSchema.safeParse(params) : undefined + const update = standard?.success ? standard.data.update : undefined const markedReplay = (envelope.success && envelope.data._meta?.isReplay === true) || extension?.replay === true - if (markedReplay && !this.replay) { - return [] + const history = markedReplay || (extension?.replay === undefined && this.load !== undefined) + const adoption = history ? this.load?.adoption : undefined + if (history && !adoption) { + return this.historyUsage(update, extension, at) } - const isReplay = extension?.replay ?? (markedReplay || this.replay !== undefined) - const providerTurn = extension?.turn - if ( - isReplay && - this.replay && - (providerTurn - ? acpJournalTurnIsSettled(this.options.journalItems(), providerTurn) - : !this.replay.adopt && !this.dialect.injectedPromptIdentity) - ) { - this.replay.pendingUser = undefined - return [] + if (adoption && update?.sessionUpdate === 'user_message_chunk') { + return this.historyUser(adoption, update, at) } - const standard = - method === 'session/update' ? SessionNotificationSchema.safeParse(params) : undefined + const providerTurn = extension?.turn + const opens = + (update !== undefined && SUBSTANTIVE_UPDATES.includes(update.sessionUpdate)) || + extension?.end !== undefined || + extension?.started === true + const turn = adoption + ? opens + ? adoption.turnFor(providerTurn) + : (providerTurn ?? adoption.turn) + : (providerTurn ?? + (this.prompts.current?.opened ? this.prompts.current.turn : this.activeTurn)) const events: ProviderTimelineEvent[] = [] - const replayUser = - isReplay && this.replay && standard?.success - ? this.replay.users.observe(standard.data.update) - : undefined - if ( - isReplay && - this.replay && - this.dialect.injectedPromptIdentity && - replayUser?.body && - !providerTurn - ) { - this.replay.pendingUser = replayUser.body - return [] - } - if (replayUser?.startsMessage && this.replay?.turn && !providerTurn) { - events.push({ type: 'turn.end', turn: this.replay.turn, at, state: 'completed' }) - this.end(this.replay.turn) - } - const substantive = - standard?.success && - [ - 'user_message_chunk', - 'agent_message_chunk', - 'agent_thought_chunk', - 'tool_call', - 'tool_call_update', - 'plan' - ].includes(standard.data.update.sessionUpdate) - let turn = - providerTurn ?? - (isReplay - ? this.replay?.turn - : this.prompts.current?.opened - ? this.prompts.current.turn - : this.activeTurn) - if (!turn && isReplay && this.replay?.adopt && substantive) { - turn = `replay:${this.replay.serial++}` - } - if (turn && (substantive || extension?.end || extension?.started)) { - events.push(...this.start(turn, extension?.at ?? at)) - if (isReplay && this.replay) { - this.replay.turn = turn - if (this.replay.pendingUser) { - const existing = acpJournalTurnHasUser(this.options.journalItems(), turn) - if (!existing) { - events.push({ - type: 'item.update', - item: `replay-user:${turn}:`, - body: this.replay.pendingUser, - join: { thread: this.options.sessionId, turn } - }) - } - this.replay.pendingUser = undefined - } - } + if (turn && opens) { + events.push( + ...(adoption + ? this.openHistory(adoption, turn, extension?.at ?? at) + : this.start(turn, extension?.at ?? at)) + ) } const join = { thread: this.options.sessionId, ...(turn === undefined ? {} : { turn }) } if (extension?.backgroundTasks) { - events.push(...this.backgroundTasks.translate(extension.backgroundTasks, join, isReplay)) + events.push(...this.backgroundTasks.translate(extension.backgroundTasks, join, history)) } if (extension?.usage) { events.push(...this.context.update(extension.usage, join)) @@ -247,23 +209,20 @@ export class AcpTimelineTranslator { return events } if (standard?.success) { + // History text is keyed by its position so a re-run of the adoption lands on the same rows. const messageKey = - turn && this.dialect.injectedPromptIdentity + turn && (this.dialect.injectedPromptIdentity || history) ? this.messages.key(turn, standard.data.update) : undefined return [ ...events, - ...acpSessionUpdate( - standard.data, - turn, - at, - isReplay, - this.tools, - this.dialect, - this.backgroundTasks, - replayUser?.body, + ...acpSessionUpdate(standard.data, turn, at, { + history, + tools: this.tools, + dialect: this.dialect, + backgroundTasks: this.backgroundTasks, messageKey - ) + }) ] } if (method === 'session/update') { @@ -278,12 +237,64 @@ export class AcpTimelineTranslator { id: string | number ): { events: ProviderTimelineEvent[]; presentation?: AcpRequestPresentation } { return translateAcpRequest(method, params, id, { - ...this.options, + sessionId: this.options.sessionId, dialect: this.dialect, tools: this.tools }) } + /** History that is not adopted is dropped except what it says about the context window. */ + private historyUsage( + update: SessionNotification['update'] | undefined, + extension: Extract | undefined, + at: number + ): ProviderTimelineEvent[] { + const join = { thread: this.options.sessionId } + const events = extension?.usage ? this.context.update(extension.usage, join) : [] + if (update?.sessionUpdate === 'usage_update') { + events.push({ type: 'context.usage', usage: acpWindowUsage(update, at), join }) + } + return events + } + + /** A saved user message opens a new history turn, unless the provider names its turn. */ + private historyUser( + adoption: AcpHistoryAdoption, + update: Extract, + at: number + ): ProviderTimelineEvent[] { + if (adoption.continuesUser(update)) { + adoption.observeUser(update) + return [] + } + const events = adoption.holdsUser + ? this.openHistory(adoption, adoption.turnFor(undefined), at) + : [] + events.push(...this.endHistory(adoption, at)) + adoption.observeUser(update) + return events + } + + private openHistory( + adoption: AcpHistoryAdoption, + turn: string, + at: number + ): ProviderTimelineEvent[] { + adoption.turn = turn + return [...this.start(turn, at), ...adoption.takeUser(turn)] + } + + /** History carries no verdict for a turn it does not end itself. */ + private endHistory(adoption: AcpHistoryAdoption, at: number): ProviderTimelineEvent[] { + const turn = adoption.turn + if (!turn) { + return [] + } + this.end(turn) + adoption.turn = undefined + return [{ type: 'turn.end', turn, at, state: 'completed' }] + } + private start(turn: string, at: number): ProviderTimelineEvent[] { if (turn === this.prompts.current?.turn) { const events = this.prompts.start(turn, at) @@ -304,8 +315,8 @@ export class AcpTimelineTranslator { if (this.activeTurn === turn) { this.activeTurn = undefined } - if (this.replay?.turn === turn) { - this.replay.turn = undefined + if (this.load?.adoption?.turn === turn) { + this.load.adoption.turn = undefined } } } diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts index 515e62c7cadc..1a4675ed4330 100644 --- a/src/main/acp/acp-tool-timeline.ts +++ b/src/main/acp/acp-tool-timeline.ts @@ -1,12 +1,11 @@ import { createPatch } from 'diff' import { z } from 'zod' -import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { - AgentJournalRenderItem, - AgentJournalToolCallItem -} from '../../shared/agent-session-journal-types' -import { acpJournalToolTurn } from './acp-journal-turns' -import { providerTimelineKeyPart } from '../native-chat/agent-session-timeline/provider-timeline-identity' +import type { AgentJournalToolCallItem } from '../../shared/agent-session-journal-types' +import { BoundedMap } from '../../shared/bounded-map' +import { + MAX_PROVIDER_TIMELINE_OPEN_BYTES, + MAX_PROVIDER_TIMELINE_OPEN_ENTRIES +} from '../native-chat/agent-session-timeline/provider-timeline-budget' import { boundPayload, boundToolInput, @@ -75,20 +74,24 @@ function boundedDiffPatch(path: string, oldText: string, newText: string): strin type ToolSnapshot = { body: AgentJournalToolCallItem; turn?: string } -/** Holds only bounded snapshots needed to fill ACP's partial tool updates. */ +/** Holds the snapshots that fill ACP's partial tool updates, settled ones evicted first. Its bound + * is the assembler's open budget, which counts each running tool at no fewer bytes, so a running + * tool is evicted only after the assembler refused one past that budget, which ends the session. + * An update for an evicted settled tool is dropped by the assembler as settled. */ export class AcpToolTimeline { private readonly tools = new Map() private bytes = 0 - - constructor(private readonly journalItems: () => readonly AgentJournalRenderItem[] = () => []) {} + /** Outlives the turn, so a later task or request still lands beside the tool that started it. */ + private readonly turns = new BoundedMap({ + maxEntries: MAX_PROVIDER_TIMELINE_OPEN_ENTRIES + }) translate( update: ToolCallUpdate, dialect: AcpDialect, join: ProviderTimelineJoin ): ProviderTimelineEvent[] { - const previous = - this.tools.get(update.toolCallId) ?? this.persisted(update.toolCallId, join.thread) + const previous = this.tools.get(update.toolCallId) const output = outputText(update) const state = update.status === 'completed' @@ -124,6 +127,9 @@ export class AcpToolTimeline { ...(join.turn === undefined ? {} : { turn: join.turn }) } this.remember(update.toolCallId, snapshot) + if (join.turn !== undefined) { + this.turns.set(update.toolCallId, join.turn) + } const events: ProviderTimelineEvent[] = [ { type: state === 'running' ? (previous ? 'item.update' : 'item.open') : 'item.close', @@ -160,22 +166,7 @@ export class AcpToolTimeline { } turn(toolCallId: string): string | undefined { - return this.tools.get(toolCallId)?.turn - } - - private persisted(callId: string, thread: string | undefined): ToolSnapshot | undefined { - const rows = this.journalItems() - const suffix = `${thread ? `${providerTimelineKeyPart(thread)}/` : ''}${providerTimelineKeyPart(`tool:${callId}`)}` - const row = rows.find((item) => { - if (item.body.kind !== 'tool-call' || item.body.callId !== callId) { - return false - } - const identity = parseAgentJournalItemKey(item.itemId) - return identity?.provider === 'legacy' && identity.recordId.endsWith(`:${suffix}`) - }) - return row?.body.kind === 'tool-call' - ? { body: row.body, turn: acpJournalToolTurn(rows, callId) } - : undefined + return this.tools.get(toolCallId)?.turn ?? this.turns.get(toolCallId) } end(turn: string): void { @@ -187,13 +178,17 @@ export class AcpToolTimeline { } } + private fits(count: number, bytes: number): boolean { + return count <= MAX_PROVIDER_TIMELINE_OPEN_ENTRIES && bytes <= MAX_PROVIDER_TIMELINE_OPEN_BYTES + } + private size(key: string, snapshot: ToolSnapshot): number { return Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(snapshot)) } private remember(key: string, snapshot: ToolSnapshot): void { const previous = this.tools.get(key) - if (this.size(key, snapshot) > 1024 * 1024) { + if (this.size(key, snapshot) > MAX_PROVIDER_TIMELINE_OPEN_BYTES) { if (previous) { this.bytes -= this.size(key, previous) this.tools.delete(key) @@ -204,7 +199,7 @@ export class AcpToolTimeline { let count = this.tools.size + (previous ? 0 : 1) const evicted: string[] = [] for (const [candidate, stored] of this.tools) { - if (count <= 128 && bytes <= 1024 * 1024) { + if (this.fits(count, bytes)) { break } if (candidate !== key && stored.body.state !== 'running') { @@ -214,7 +209,7 @@ export class AcpToolTimeline { } } for (const [candidate, stored] of this.tools) { - if (count <= 128 && bytes <= 1024 * 1024) { + if (this.fits(count, bytes)) { break } if (candidate !== key && !evicted.includes(candidate)) { From 430d82053599e34f36282fb816e6ed82d44f33fa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:30:57 -0700 Subject: [PATCH 134/269] Leave a stopped turn's running tools to the agent's own end When another writer settles the open turn (a person's Stop), the assembler now only stops that turn's text and cancels its pending prompts. Running tool calls stay the agent's: a progress update or completion it reports after the Stop lands as reported, and whatever is still running settles at the agent's turn end for that turn, the next turn's open, or the session's end. An agent's end for an earlier turn while a newer one is open no longer clears the open turn's activity line or ends its anonymous reply. An unnamed end right after a Stop ends the stopped turn instead of being dropped. The test rig's restart no longer writes the dead assembler's window text, matching dispose. --- ...imeline-assembler-earlier-turn-end.test.ts | 62 +++++++++++ .../provider-timeline-assembler-stop.test.ts | 102 +++++++++++++++++- ...rovider-timeline-assembler-test-support.ts | 9 +- .../provider-timeline-assembler.ts | 13 +-- .../provider-timeline-decision.ts | 6 +- .../provider-timeline-event.ts | 14 ++- .../provider-timeline-item-decisions.ts | 4 +- .../provider-timeline-settlement.ts | 10 +- .../provider-timeline-state.ts | 33 +++++- .../provider-timeline-transition-layout.ts | 19 ++-- .../provider-timeline-turn-decisions.ts | 67 ++++++------ 11 files changed, 271 insertions(+), 68 deletions(-) create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-earlier-turn-end.test.ts diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-earlier-turn-end.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-earlier-turn-end.test.ts new file mode 100644 index 000000000000..0e79ec50d040 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-earlier-turn-end.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + providerItemId, + providerTurnId, + providerTurnItemId, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +describe("an earlier turn's late end leaves the open turn alone", () => { + it("keeps the open turn's activity line", async () => { + const rig = await openProviderTimelineRig() + const activity: unknown[] = [] + const assembler = rig.assemble({ + sink: { ...rig.sink, setActivity: (each) => activity.push(each) } + }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + assembler.apply({ type: 'turn.end', turn: 't1', at: 1_500, state: 'completed' }) + assembler.apply({ type: 'turn.open', turn: 't2', at: 2_000 }) + assembler.apply({ type: 'activity', text: 'Thinking' }) + activity.length = 0 + expect( + assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'completed' }) + ).toEqual({ admission: { accepted: true } }) + expect(activity).toEqual([]) + expect(assembler.openTurnId).toBe(providerTurnId('t2')) + }) + + it("keeps the open turn's anonymous reply one message", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 1_500, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 2_000 }) + const delta = { type: 'text.delta', item: { stream: 'reply' }, channel: 'assistant' } as const + rig.assembler.apply({ ...delta, text: 'Hello' }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'completed' }) + rig.assembler.apply({ ...delta, text: ' world' }) + const texts = (await rig.rows()).flatMap((row) => + row.body.kind === 'message' ? [[row.turnScope, messageText(row.body)]] : [] + ) + expect(texts).toEqual([[{ kind: 'turn', turnItemId: providerTurnItemId('t2') }, 'Hello world']]) + }) + + it("leaves the open turn's running work to that turn's own end", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 2_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'completed' }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'running' + }) + rig.assembler.apply({ type: 'turn.end', turn: 't2', at: 2_200, state: 'interrupted' }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'failed' + }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts index 7ce1ec3a3f76..4ee9190ad244 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts @@ -9,6 +9,7 @@ import { openUnboundProviderTimelineAssembler, pendingApproval, providerItemId, + providerTurnId, providerTurnItemId, runningTool, type ProviderTimelineRig @@ -30,6 +31,8 @@ async function stop(rig: ProviderTimelineRig, turnKey: string, at = 2_000): Prom ) } +const completed = { ...runningTool('read'), state: 'completed' as const } + async function texts(rig: ProviderTimelineRig): Promise { return (await rig.rows()).flatMap((row) => { const text = messageText(row.body) @@ -39,8 +42,8 @@ async function texts(rig: ProviderTimelineRig): Promise { }) } -describe('a turn another writer settled ends as the provider end would end it', () => { - it("settles the turn's running tool and pending prompt, then takes the provider's own end", async () => { +describe('a turn another writer settled stops its text and prompts; its tools wait for the provider', () => { + it("cancels the turn's prompt at once and settles its running tool at the provider's own end", async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) @@ -80,15 +83,92 @@ describe('a turn another writer settled ends as the provider end would end it', expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', completedAt: 2_100 }) }) - it('settles the work even when the next event is one that writes nothing', async () => { + it('cancels the prompt even when the next event writes nothing, and leaves the tool running', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + rig.assembler.apply({ type: 'request.open', request: '7', body: pendingApproval }) await stop(rig, 't1') expect(rig.assembler.apply({ type: 'activity', text: 'Thinking' }).dropped).toBe('no-turn') + expect(rig.assembler.openTurnId).toBeNull() + expect((await rig.row(providerItemId('request', '7')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'running' + }) + }) + + it('lands a tool the provider completes after the Stop as completed', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + await stop(rig, 't1') + // Its progress, then its completion, both reported between the cancel and the turn's end. + const progress = { ...runningTool('read'), output: 'half' } + expect( + rig.assembler.apply({ + type: 'item.update', + item: 'call', + body: progress, + join: { turn: 't1' } + }) + ).toEqual({ admission: { accepted: true } }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ output: 'half' }) + expect( + rig.assembler.apply({ + type: 'item.close', + item: 'call', + body: completed, + join: { turn: 't1' } + }) + ).toEqual({ admission: { accepted: true } }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'interrupted' }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'completed' + }) + }) + + it('lands the completion the same way when it arrives before the Stop', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + rig.assembler.apply({ type: 'item.close', item: 'call', body: completed, join: { turn: 't1' } }) + await stop(rig, 't1') + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'interrupted' }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'completed' + }) + }) + + it("ends the stopped turn on the provider's unnamed end", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + await stop(rig, 't1') + expect(rig.assembler.apply({ type: 'turn.end', at: 2_100, state: 'interrupted' })).toEqual({ + admission: { accepted: true } + }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'failed' + }) + // Ended once: a second unnamed end names no turn. + expect(rig.assembler.apply({ type: 'turn.end', at: 2_200, state: 'interrupted' }).dropped).toBe( + 'no-turn' + ) + }) + + it('settles the running tools of a stopped turn the provider never ended when the next opens', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + await stop(rig, 't1') + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ state: 'failed' }) + expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', completedAt: 2_000 }) + expect(rig.assembler.openTurnId).toBe(providerTurnId('t2')) }) it('never fills the open budget with the streams of turns a person stopped', async () => { @@ -157,6 +237,22 @@ describe('text after a person stopped its turn never lands outside that turn', ( ]) }) + it("keeps dropping an anonymous stream after the provider's unnamed end until the next turn", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + const delta = { type: 'text.delta', item: { stream: 'a' }, channel: 'assistant' } as const + rig.assembler.apply({ ...delta, text: 'Hel' }) + await stop(rig, 't1') + expect(rig.assembler.apply({ ...delta, text: 'lo' }).dropped).toBe('turn-settled') + rig.assembler.apply({ type: 'turn.end', at: 2_100, state: 'interrupted' }) + // The provider's end of the stopped turn is that turn's boundary: its marker is gone. + expect(rig.assembler.apply({ ...delta, text: 'B' }).dropped).toBeUndefined() + expect(await texts(rig)).toEqual([ + [{ kind: 'turn', turnItemId: providerTurnItemId('t1') }, 'Hel'], + [{ kind: 'thread' }, 'B'] + ]) + }) + it('drops the rest of a named stream whose first write found the turn stopped', async () => { const rig = await openProviderTimelineRig({ schedule: () => () => {} }) rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts index 1009ab6aa532..b0e5eb833a22 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -191,9 +191,9 @@ export type ProviderTimelineRig = { sink: ProviderTimelineSink /** The same journal's event sink, for a lane that writes it directly. */ eventSink: StructuredAgentSessionEventSink - /** What a restarted host does: the old child's assembler is gone, the dead-generation sweep - * settles what it left, then a new child gets a new assembler in a new generation, which - * becomes `assembler`. */ + /** What a restarted host does: the old child's assembler is gone (text still in its window is + * lost, as `dispose` drops it), the dead-generation sweep settles what it left, then a new + * child gets a new assembler in a new generation, which becomes `assembler`. */ restart(overrides?: Partial): Promise /** Another assembler of the same generation on the same journal, for a test that needs its own * sink; the rig's own assembler must then stay unused. */ @@ -285,7 +285,8 @@ export async function openProviderTimelineRig( restart: async (more = {}) => { generations += 1 const generation = more.generation ?? `gen-${generations}` - await rows() + // The dead child's window never elapses: as in production, dispose drops its text. + await deferred.drained() rig.assembler.dispose() await settleStaleStructuredAgentSessionState({ journal, diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts index 0bc2da8b7204..a4bd51d4e807 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -124,25 +124,26 @@ export function createProviderTimelineAssembler( return { admission: PROVIDER_TIMELINE_OVER_BUDGET } } const plan = new ProviderTimelinePlan() - planProviderTimelineBarrier({ streams, state, context }, plan, event, decision) + planProviderTimelineBarrier({ streams, state }, plan, event, decision) planProviderTimelineWrites(context, plan, decision, serials.next) plan.onAdmitted(() => { decision.commit?.(state) serials.commit() }) + // An earlier turn's end leaves the open turn's activity line alone. if ( event.type === 'turn.open' || - event.type === 'turn.end' || - event.type === 'turn.settled' || - event.type === 'session.ended' + event.type === 'session.ended' || + decision.ends?.current === true ) { plan.onAdmitted(() => deps.sink.setActivity?.(null)) } return { admission: plan.submit(deps.sink) } } - /** The open turn another writer settled (a person's Stop) ends here first, as one transition; - * refused, the event that found it is refused with it and its retry finds it again. */ + /** The open turn another writer settled (a person's Stop) ends here first, as one transition + * that stops its text and cancels its prompts; refused, the event that found it is refused with + * it and its retry finds it again. */ const endSettledTurn = ( journal: NonNullable ): ProviderTimelineApplyResult | null => { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts index 884f735b50fe..e5c985aaece1 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -50,7 +50,8 @@ export type ProviderTimelineDropRule = export type ProviderTimelineDecidedEvent = | Exclude /** The assembler's own: the journal shows the open turn settled by another writer (a person's - * Stop), so it ends here exactly as the provider's `turn.end` would end it. */ + * Stop), so its text stops and its prompts are cancelled; its running tool calls wait for the + * provider's own `turn.end`. */ | { type: 'turn.settled'; turn: ProviderTimelineTurnRef } type Journal = StructuredAgentSessionTransitionJournal @@ -80,6 +81,9 @@ export type ProviderTimelineDecision = { writes?: readonly ProviderTimelineItemWrite[] /** The streamed item whose text this event's full snapshot replaces. */ closes?: string + /** The turn this event ends; `current` unless another turn is open, whose text and activity + * an earlier turn's end leaves alone. */ + ends?: { turnItemId: string; current: boolean } } export type ProviderTimelineDecisionInput = { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts index b578088d8ef2..dd2f7d1497fb 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -18,9 +18,11 @@ // - A turn settles once, with only the provider's verdict. A row the journal already holds is // never written back to an earlier state: a settled turn stays settled, a settled tool keeps its // terminal body, an answered request stays answered. -// - A turn another writer settled (a person's Stop) ends here as the provider's `turn.end` would -// end it: its running work settles, and text still streaming into it is dropped until the -// provider's end of that turn or the next `turn.open`. +// - A turn another writer settled (a person's Stop) is over here: its pending requests are +// cancelled, and text still streaming into it is dropped until the provider's end of that turn +// or the next `turn.open`. Its running tool calls are still the provider's: a close reported +// after the Stop lands as reported, and whatever is still running settles at the provider's +// `turn.end` for it (or the next `turn.open`, or `session.ended`). // - Saved history the provider replays goes only into an empty journal (an adopted session), as // ordinary events with the provider's own ids, so re-running an interrupted adoption writes the // same rows again. Limit, until the adoption work lifts it: a crash that cut the first run @@ -92,8 +94,10 @@ export type ProviderTimelineEvent = } /** A turn began. `turn` is the provider's turn id when it has one; the assembler mints one otherwise. */ | { type: 'turn.open'; turn?: string; at: number } - /** The provider ended a turn. Absent `turn` means the open turn. A turn already over (another - * writer's Stop, a newer turn) takes it too: what it left open settles, its row stays. */ + /** The provider ended a turn. Absent `turn` means the open turn, else the one a person stopped + * that the provider had not ended yet. A turn already over (another writer's Stop, a newer + * turn) takes it too: what it left open settles, its row stays, and the open turn's text and + * activity are untouched. */ | { type: 'turn.end' turn?: string diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts index db7ee10131b6..22e8a1c6f1e6 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts @@ -41,7 +41,8 @@ function pendingPrompt(body: AgentJournalItemBody | null): body is ProviderTimel /** Whether the row the journal holds refuses this write: a settled tool keeps its first terminal * body (whoever settled it, the sweep included), a settled background task is never relit, and a - * turn that is over takes no work that waits on a settlement it already ran. */ + * turn that is over takes no new work that waits on a settlement. Work it still holds open (a + * person's Stop leaves the provider's running tools) takes the provider's updates until settled. */ function refusesItemWrite( journal: Journal, row: ProviderTimelineRowId, @@ -63,6 +64,7 @@ function refusesItemWrite( } return ( requiresTerminalSettlement(body) && + !(held && requiresTerminalSettlement(held.body)) && turn !== null && providerTimelineTurnRowState(journal, turn) === 'settled' ) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts index 1baa7c54996b..24d35559a2ed 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -61,8 +61,9 @@ export function endedProviderTimelineTurn( } } -/** Which open rows a settlement covers: one turn's, or every row when the session ends. */ -export type ProviderTimelineSettlementScope = { turnItemId: string } | 'session' +/** Which open rows a settlement covers: one turn's, or every row when the session ends. A turn + * another writer settled covers only its prompts: its tool calls are the provider's to finish. */ +export type ProviderTimelineSettlementScope = { turnItemId: string; promptsOnly?: true } | 'session' /** The turns a settlement ends, and how; absent when another writer already ended the turn. */ export type ProviderTimelineSettlementEnding = { @@ -81,7 +82,9 @@ export function providerTimelineSettlement( const turnScope = attribution.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE const covered = scope === 'session' || - (turnScope.kind === 'turn' && turnScope.turnItemId === scope.turnItemId) + (turnScope.kind === 'turn' && + turnScope.turnItemId === scope.turnItemId && + !(scope.promptsOnly && body.kind === 'tool-call')) // Background tasks and subagents outlive turns; only the session's end leaves them past seeing. const settled = !covered ? null @@ -129,6 +132,7 @@ function endedTurnRow( const target = { identity: turn.identity } const write = { lifecycle: agentJournalTurnBody(endedProviderTimelineTurn(running, end)), + // Defence only: the check above reads the same snapshot; the revision refuses an ended row too. onlyWhileRunning: true as const } return resolveAgentJournalTurnRowWrite( diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts index 22d461b8ba87..aa8b0a1bc82c 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -53,6 +53,9 @@ export class ProviderTimelineState { open: ProviderTimelineOpenTurn | null = null /** The turn that ended last, for context facts that arrive after it. */ latest: ProviderTimelineTurnRef | null = null + /** The turn another writer settled (a person's Stop) that the provider has not ended yet: its + * running work is the provider's until then. */ + stopped: ProviderTimelineTurnRef | null = null inputs: ProviderTimelinePendingInput[] = [] /** Keyed by row id (an item) or `request:` (a request, whatever its incarnation). */ items = new Map() @@ -126,16 +129,38 @@ export class ProviderTimelineState { this.open = null this.latest = turn } - for (const [key, entry] of this.items) { - if (entry.turnItemId === turn.itemId) { - this.items.delete(key) - } + if (this.stopped?.itemId === turn.itemId) { + this.stopped = null } + this.forget(turn, () => true) + } + + /** Another writer settled the open turn: it is over here and its prompts were cancelled with it, + * but its running work stays open until the provider ends the turn. */ + stopTurn(turn: ProviderTimelineTurnRef): void { + if (this.open?.itemId === turn.itemId) { + this.open = null + this.latest = turn + this.stopped = turn + } + this.forget(turn, (entry) => entry.kind === 'request') } endSession(): void { this.ended = true this.open = null + this.stopped = null this.items.clear() } + + private forget( + turn: ProviderTimelineTurnRef, + which: (entry: ProviderTimelineOpenItem) => boolean + ): void { + for (const [key, entry] of this.items) { + if (entry.turnItemId === turn.itemId && which(entry)) { + this.items.delete(key) + } + } + } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts index e497b5a66df1..f2c221ed8e81 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts @@ -10,7 +10,7 @@ import type { ProviderTimelineDecision } from './provider-timeline-decision' import type { ProviderTimelinePlan } from './provider-timeline-plan' -import { providerKey, turnOf } from './provider-timeline-rows' +import { turnOf } from './provider-timeline-rows' import type { ProviderTimelineState } from './provider-timeline-state' import type { ProviderTimelineTextStreams } from './provider-timeline-text-streams' @@ -19,19 +19,19 @@ const SETTLEMENT_RESERVED_BYTES = 64 * 1024 /** Text owed ahead of the event lands first; a row-writing event also ends the messages it * separates: anonymous ones of its producer, every stream of a turn it ends, all on a session end. - * A turn another writer settled stops its streams until that turn's boundary: its own end, or - * the next turn's open. */ + * An earlier turn's end, while another turn is open, ends only that earlier turn's streams. A turn + * another writer settled stops its streams until that turn's boundary: the provider's end of it, + * or the next turn's open. */ export function planProviderTimelineBarrier( input: { streams: ProviderTimelineTextStreams state: ProviderTimelineState - context: ProviderTimelineContext }, plan: ProviderTimelinePlan, event: ProviderTimelineDecidedEvent, decision: ProviderTimelineDecision ): void { - const { streams, state, context } = input + const { streams, state } = input // A full snapshot of a streamed message replaces what streamed, so that text is not flushed. const replaced = decision.closes ? streams.get(decision.closes) : undefined streams.planFlush(plan, replaced) @@ -44,11 +44,8 @@ export function planProviderTimelineBarrier( } if (event.type === 'turn.end' || event.type === 'turn.open' || event.type === 'turn.settled') { const ending = - event.type === 'turn.settled' - ? event.turn.itemId - : event.type === 'turn.end' && event.turn !== undefined - ? context.rows.turn(providerKey(event.turn)).itemId - : state.open?.itemId + decision.ends?.turnItemId ?? (event.type === 'turn.open' ? state.open?.itemId : undefined) + const current = event.type === 'turn.open' || decision.ends?.current === true if (event.type === 'turn.settled') { streams.planStop(plan, event.turn.itemId) } else if (event.type === 'turn.open') { @@ -59,7 +56,7 @@ export function planProviderTimelineBarrier( streams.planRelease( plan, (stream) => - (!stream.named && stream.producer?.agentId === undefined) || + (current && !stream.named && stream.producer?.agentId === undefined) || (ending !== undefined && turnOf(stream.scope) === ending) ) return diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts index 9584ef55ad85..e9c4471f6076 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts @@ -32,7 +32,7 @@ import { import { providerTimelineSettlement, runningProviderTimelineTurns, - type ProviderTimelineSettlementEnding + type ProviderTimelineTurnEnd } from './provider-timeline-settlement' import type { ProviderTimelineOpenTurn, @@ -48,6 +48,8 @@ export function decideTurnOpen( ): ProviderTimelineDecision { const { state, journal, context } = input const open = state.open + // A newer turn ends this one, whoever asked for it; or the stopped one the provider never ended. + const superseded = open ?? state.stopped // An open naming no turn while one is open is the same turn, not a new one. if (event.turn === undefined && open) { return { dropped: 'turn-duplicate' } @@ -75,17 +77,16 @@ export function decideTurnOpen( body: agentJournalTurnBody(running) } return { - // A newer turn ended this one, whoever asked for it. - ...(open + ...(superseded ? { settle: { what: 'turn-superseded', resolve: (journal) => providerTimelineSettlement( journal, - { turnItemId: open.itemId }, + { turnItemId: superseded.itemId }, { - turns: [open], + turns: [superseded], end: { state: 'interrupted', completedAt: event.at, outcome: 'superseded' } } ) @@ -103,8 +104,8 @@ export function decideTurnOpen( } ], commit: (next) => { - if (next.open) { - next.endTurn(next.open) + if (superseded) { + next.endTurn(superseded) } if (pending) { next.inputs = next.inputs.filter((each) => each !== pending) @@ -119,8 +120,11 @@ export function decideTurnEnd( event: Extract ): ProviderTimelineDecision { const { state, journal } = input + // Unnamed: the open turn, else the one a person stopped, which the provider is now ending. const turn = - event.turn === undefined ? state.open : input.context.rows.turn(providerKey(event.turn)) + event.turn === undefined + ? (state.open ?? state.stopped) + : input.context.rows.turn(providerKey(event.turn)) if (!turn) { return { dropped: 'no-turn' } } @@ -134,36 +138,38 @@ export function decideTurnEnd( if (!known) { return { dropped: 'turn-unknown' } } - return endedTurn(turn, { - turns: [turn], - end: { - state: event.state, - completedAt: event.at, - ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), - ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) - } - }) + const end: ProviderTimelineTurnEnd = { + state: event.state, + completedAt: event.at, + ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), + ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) + } + return { + ends: { turnItemId: turn.itemId, current: !state.open || state.open.itemId === turn.itemId }, + settle: { + what: 'turn-end', + resolve: (journal) => + providerTimelineSettlement(journal, { turnItemId: turn.itemId }, { turns: [turn], end }) + }, + commit: (next) => next.endTurn(turn) + } } -/** The open turn another writer settled: its work ends as the provider's end would end it, and - * its row stays as that writer left it. */ +/** The open turn another writer settled (a person's Stop): its text stops and its prompts are + * cancelled; its row stays as that writer left it, and its running tool calls stay the + * provider's until the provider ends the turn (or a newer turn, or the session's end, does). */ export function decideTurnSettled( event: Extract ): ProviderTimelineDecision { - return endedTurn(event.turn) -} - -/** The one end of a turn: the journal-derived settlement of its open work, then the state. */ -function endedTurn( - turn: ProviderTimelineTurnRef, - ending?: ProviderTimelineSettlementEnding -): ProviderTimelineDecision { + const { turn } = event return { + ends: { turnItemId: turn.itemId, current: true }, settle: { - what: ending ? 'turn-end' : 'turn-settled', - resolve: (journal) => providerTimelineSettlement(journal, { turnItemId: turn.itemId }, ending) + what: 'turn-settled', + resolve: (journal) => + providerTimelineSettlement(journal, { turnItemId: turn.itemId, promptsOnly: true }) }, - commit: (next) => next.endTurn(turn) + commit: (next) => next.stopTurn(turn) } } @@ -263,6 +269,7 @@ function reviseOpener( const target = { identity: open.identity } const write = { lifecycle: agentJournalTurnBody({ ...row, ...message }), + // Defence only: the check above reads the same snapshot; the revision refuses an ended row too. onlyWhileRunning: true as const } return resolveAgentJournalTurnRowWrite( From d22b154908646d91f8d9b3791cab5cb0a60cb5bd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:31:39 -0700 Subject: [PATCH 135/269] Pin that a stopped turn's running tools hold budget until the agent's end --- .../provider-timeline-assembler-stop.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts index 4ee9190ad244..d554eee9d91d 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts @@ -171,6 +171,19 @@ describe('a turn another writer settled stops its text and prompts; its tools wa expect(rig.assembler.openTurnId).toBe(providerTurnId('t2')) }) + it("counts a stopped turn's running tools against the budget until the provider ends it", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + for (let n = 1; n <= MAX_PROVIDER_TIMELINE_OPEN_ENTRIES; n += 1) { + rig.assembler.apply({ type: 'item.open', item: `call-${n}`, body: runningTool('read') }) + } + await stop(rig, 't1') + const extra = { type: 'item.open', item: 'extra', body: runningTool('read') } as const + expect(rig.assembler.apply(extra).admission).toEqual({ accepted: false, reason: 'failed' }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'interrupted' }) + expect(rig.assembler.apply(extra).admission).toEqual({ accepted: true }) + }) + it('never fills the open budget with the streams of turns a person stopped', async () => { const rig = await openProviderTimelineRig() for (let n = 1; n <= MAX_PROVIDER_TIMELINE_OPEN_ENTRIES + 12; n += 1) { From 1c49877cbb4ff58c38b8cb3f9ee207bd6590862c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:33:08 -0700 Subject: [PATCH 136/269] Type the stopped turn's tool progress update as a tool body --- .../provider-timeline-assembler-stop.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts index d554eee9d91d..aaaf89b814de 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts @@ -105,7 +105,7 @@ describe('a turn another writer settled stops its text and prompts; its tools wa rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) await stop(rig, 't1') // Its progress, then its completion, both reported between the cancel and the turn's end. - const progress = { ...runningTool('read'), output: 'half' } + const progress = { ...runningTool('read'), input: { name: 'read', path: 'a.ts' } } expect( rig.assembler.apply({ type: 'item.update', @@ -114,7 +114,9 @@ describe('a turn another writer settled stops its text and prompts; its tools wa join: { turn: 't1' } }) ).toEqual({ admission: { accepted: true } }) - expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ output: 'half' }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + input: { path: 'a.ts' } + }) expect( rig.assembler.apply({ type: 'item.close', From 9fcb95406eaa1a8d327194836bc1c54d7a4fb811 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:45:09 -0700 Subject: [PATCH 137/269] List every event the assembler hands to the decision step The type-aware lint requires an exhaustive switch with no default case. Also retitle a Stop test to say what it asserts. --- .../provider-timeline-assembler-stop.test.ts | 2 +- .../provider-timeline-assembler.ts | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts index aaaf89b814de..e0e4541402be 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts @@ -252,7 +252,7 @@ describe('text after a person stopped its turn never lands outside that turn', ( ]) }) - it("keeps dropping an anonymous stream after the provider's unnamed end until the next turn", async () => { + it("drops a stopped turn's anonymous stream until the provider's unnamed end, then lands it at thread level", async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) const delta = { type: 'text.delta', item: { stream: 'a' }, channel: 'assistant' } as const diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts index a4bd51d4e807..f1b6cb0a68b0 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -175,7 +175,18 @@ export function createProviderTimelineAssembler( ) return { admission: ADMITTED } } - default: + case 'input.accepted': + case 'input.history': + case 'turn.open': + case 'turn.end': + case 'item.open': + case 'item.update': + case 'item.close': + case 'request.open': + case 'request.withdrawn': + case 'context.usage': + case 'provider.frame': + case 'session.ended': return applyDecided(event, journal) } } From a46e8b8a4eb4eab544c41a7c37671625632e0818 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:51:48 -0700 Subject: [PATCH 138/269] End a running call as its turn's journal row ends A call still running when its turn ends takes the state of that turn's row: a row another writer settled first (a person's Stop) stands, so its calls read interrupted whatever the provider's later end reports. The no-ending path that settled calls from the Stop row is gone, since a Stop now leaves running calls to the provider. Adds the two Spanish strings. --- .../journal-terminal-settlement.ts | 5 +- ...vider-timeline-assembler-tool-ends.test.ts | 64 +++++++++++++------ .../provider-timeline-settlement.ts | 40 ++++++------ .../provider-timeline-turn-decisions.ts | 3 +- src/renderer/src/i18n/locales/es.json | 2 + 5 files changed, 73 insertions(+), 41 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts b/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts index bc2a23f5b68f..efddcfb6e17c 100644 --- a/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts +++ b/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts @@ -20,13 +20,14 @@ export function requiresTerminalSettlement(body: AgentJournalItemBody): boolean /** The row that settles an item no one will finish: a running tool call ends as `end` (how its * turn or session ended) says, a pending prompt is cancelled. Null for an item that needs none. + * A null `end` ends no call: another writer settled the turn, and its calls stay the provider's. * Turn rows are each writer's own to end. */ export function terminalAgentJournalBody( body: AgentJournalItemBody, - end: AgentJournalRunningCallEnd + end: AgentJournalRunningCallEnd | null ): AgentJournalItemBody | null { if (body.kind === 'tool-call') { - return body.state === 'running' ? endedRunningAgentJournalToolCall(body, end) : null + return body.state === 'running' && end ? endedRunningAgentJournalToolCall(body, end) : null } if (body.kind === 'approval' || body.kind === 'question') { return body.resolution.state === 'pending' ? cancelledJournalPromptBody(body) : null diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-tool-ends.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-tool-ends.test.ts index d571818e4fbf..11e18d7a38a8 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-tool-ends.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-tool-ends.test.ts @@ -39,6 +39,25 @@ async function openTool(): Promise { return rig } +/** As a person's Stop writes it, straight to the journal: the turn interrupted, as their cancellation. */ +async function personStops(rig: ProviderTimelineRig): Promise { + const running = await rig.turn('turn-1') + const identity = parseAgentJournalItemKey(providerTurnItemId('turn-1')) + if (!running || !identity) { + throw new Error('the turn row is written') + } + await rig.journal.appendItem( + identity, + agentJournalTurnBody({ + ...running, + state: 'interrupted', + outcome: 'cancellation', + completedAt: 2_000 + }), + { fence: 1, turnScope: { kind: 'thread' } } + ) +} + describe('provider timeline: how a call its turn or session ended reads', () => { it('reads interrupted when a stop interrupts its turn, keeping failed for older builds', async () => { const rig = await openTool() @@ -67,26 +86,33 @@ describe('provider timeline: how a call its turn or session ended reads', () => expect(await lifecycle(rig)).toBe('interrupted') }) - it('reads interrupted when a person stops its turn on the journal, with no word to the assembler', async () => { - const rig = await openTool() - const running = await rig.turn('turn-1') - const identity = parseAgentJournalItemKey(providerTurnItemId('turn-1')) - if (!running || !identity) { - throw new Error('the turn row is written') + it.each(['interrupted', 'completed'] as const)( + "reads interrupted when a person stopped its turn, whatever the provider's later end (%s) says", + async (providerEnd) => { + const rig = await openTool() + await personStops(rig) + // The Stop leaves the call the provider's to finish. + rig.assembler.apply({ type: 'activity', text: 'Thinking' }) + expect(await toolBody(rig)).toMatchObject({ state: 'running' }) + rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 2_100, state: providerEnd }) + expect(await toolBody(rig)).toMatchObject({ state: 'failed', endedAs: 'interrupted' }) + expect(await rig.turn('turn-1')).toMatchObject({ state: 'interrupted', completedAt: 2_000 }) } - // As a person's Stop writes it: the turn interrupted, as their cancellation. - await rig.journal.appendItem( - identity, - agentJournalTurnBody({ - ...running, - state: 'interrupted', - outcome: 'cancellation', - completedAt: 2_000 - }), - { fence: 1, turnScope: { kind: 'thread' } } - ) - rig.assembler.apply({ type: 'activity', text: 'Thinking' }) - expect(await toolBody(rig)).toMatchObject({ state: 'failed', endedAs: 'interrupted' }) + ) + + it('keeps a call the provider completed after a person stopped its turn completed', async () => { + const rig = await openTool() + rig.assembler.apply({ type: 'item.open', item: 'call-b', body: runningTool('read') }) + await personStops(rig) + rig.assembler.apply({ + type: 'item.close', + item: 'call-a', + body: { ...runningTool('shell'), state: 'completed' }, + join: { turn: 'turn-1' } + }) + rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 2_100, state: 'interrupted' }) + expect(await lifecycle(rig, 'call-a')).toBe('completed') + expect(await lifecycle(rig, 'call-b')).toBe('interrupted') }) it('leaves the call unverified when a restarted host settles it with no proof the child died', async () => { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts index c3a4456746ad..b5f5e231559a 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -12,7 +12,8 @@ import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemBody, type AgentJournalItemIdentity, - type AgentJournalTurnLifecycle + type AgentJournalTurnLifecycle, + type AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import { agentJournalTurnBody, @@ -62,11 +63,11 @@ export function endedProviderTimelineTurn( } } -/** Which open rows a settlement covers: one turn's, or every row when the session ends. A turn - * another writer settled covers only its prompts: its tool calls are the provider's to finish. */ -export type ProviderTimelineSettlementScope = { turnItemId: string; promptsOnly?: true } | 'session' +/** Which open rows a settlement covers: one turn's, or every row when the session ends. */ +export type ProviderTimelineSettlementScope = { turnItemId: string } | 'session' -/** The turns a settlement ends, and how; absent when another writer already ended the turn. */ +/** The turns a settlement ends, and how; absent when another writer already ended the turn, which + * settles only its prompts: its tool calls are the provider's to finish. */ export type ProviderTimelineSettlementEnding = { turns: readonly { identity: AgentJournalItemIdentity; itemId: string }[] end: ProviderTimelineTurnEnd @@ -79,14 +80,15 @@ export function providerTimelineSettlement( ending?: ProviderTimelineSettlementEnding ): JournalLifecycleMutationInput[] { const mutations: JournalLifecycleMutationInput[] = [] - const end = ending?.end.state ?? settledTurnEnd(journal, scope) journal.visitItemsWithLinkage((itemId, _sequence, body, attribution) => { const turnScope = attribution.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE const covered = scope === 'session' || - (turnScope.kind === 'turn' && - turnScope.turnItemId === scope.turnItemId && - !(scope.promptsOnly && body.kind === 'tool-call')) + (turnScope.kind === 'turn' && turnScope.turnItemId === scope.turnItemId) + const end = + covered && ending && body.kind === 'tool-call' && body.state === 'running' + ? runningCallEnd(journal, turnScope, ending.end) + : null // Background tasks and subagents outlive turns; only the session's end leaves them past seeing. const settled = !covered ? null @@ -107,17 +109,19 @@ export function providerTimelineSettlement( return mutations } -/** A turn another writer settled (a person's Stop) ends its calls as its row says, as the same end - * from the provider would; a row that proves no end leaves them unverified, open to a later proof. */ -function settledTurnEnd( +/** A call still running when its turn ends ends as that turn's row ends. A row another writer + * settled first (a person's Stop) stands, so its calls take that row's state, not the provider's + * later report; a row this settlement ends, and work outside any turn, take this settlement's end. */ +function runningCallEnd( journal: StructuredAgentSessionTransitionJournal, - scope: ProviderTimelineSettlementScope + turnScope: AgentJournalTurnScope, + end: ProviderTimelineTurnEnd ): AgentJournalRunningCallEnd { - const turn = - scope === 'session' - ? null - : readAgentJournalTurn(journal.itemBody(scope.turnItemId) ?? undefined) - return turn && turn.state !== 'running' ? turn.state : 'unverifiable' + const row = + turnScope.kind === 'turn' + ? readAgentJournalTurn(journal.itemBody(turnScope.turnItemId) ?? undefined) + : null + return row && row.state !== 'running' ? row.state : end.state } /** Every turn row the journal holds running, for the session's end. */ diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts index e9c4471f6076..3f8c01f977f7 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts @@ -166,8 +166,7 @@ export function decideTurnSettled( ends: { turnItemId: turn.itemId, current: true }, settle: { what: 'turn-settled', - resolve: (journal) => - providerTimelineSettlement(journal, { turnItemId: turn.itemId, promptsOnly: true }) + resolve: (journal) => providerTimelineSettlement(journal, { turnItemId: turn.itemId }) }, commit: (next) => next.stopTurn(turn) } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4f002551643a..4432b85bde38 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17673,6 +17673,8 @@ "moreCalls": "+{{value0}} más", "failedCount": "{{value0}} fallidas", "failedCallsLabel": "Llamadas a herramientas fallidas: {{value0}}", + "interruptedCount": "{{value0}} interrumpidas", + "interruptedCallsLabel": "Llamadas a herramientas interrumpidas: {{value0}}", "runningPreview": "Ejecutando {{preview}}", "runningCommand": "Ejecutando comando", "runningNamedPreview": "Ejecutando {{toolName}} {{preview}}", From cf9d0ee4b0ec46c8edce1cf29c731ef2000b4ed0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:57:06 -0700 Subject: [PATCH 139/269] Say why a Grok turn failed, and keep task rows in Grok's own words A failed Grok turn ended with no reason on screen: the translator dropped every copy of Grok's message. The failed turn now gets one status row in Orca's existing "provider did not accept this message" words with Grok's reason, read from whichever copy arrives first (the given-up retry, the turn's end, the prompt's completion notice, or the prompt's error answer); later copies only fill a reason the row still lacks. A running background command no longer reads "Background task started": a task's summary is mapped only once it has settled. A monitor stays a monitor when the agent reads its output: a frame that names no kind keeps the known one, and a "[monitor" command is a monitor. A prompt's turn is marked started, so a late frame for an ended prompt neither reopens it nor becomes the active turn. A tool's turn is held in one place at a time. --- src/main/acp/acp-background-task-timeline.ts | 4 +- src/main/acp/acp-context-usage.ts | 16 +- src/main/acp/acp-dialects/acp-dialect.ts | 8 +- .../acp/acp-dialects/grok-background-tasks.ts | 24 +- src/main/acp/acp-dialects/grok-dialect.ts | 58 ++++- src/main/acp/acp-prompt-turns.ts | 7 + ...acp-timeline-background-task-words.test.ts | 97 ++++++++ src/main/acp/acp-timeline-translator.ts | 80 +++--- .../acp/acp-timeline-turn-failures.test.ts | 232 ++++++++++++++++++ src/main/acp/acp-tool-timeline.ts | 33 ++- src/main/acp/acp-turn-failures.ts | 57 +++++ 11 files changed, 547 insertions(+), 69 deletions(-) create mode 100644 src/main/acp/acp-timeline-background-task-words.test.ts create mode 100644 src/main/acp/acp-timeline-turn-failures.test.ts create mode 100644 src/main/acp/acp-turn-failures.ts diff --git a/src/main/acp/acp-background-task-timeline.ts b/src/main/acp/acp-background-task-timeline.ts index 058223ac1520..414e9f6c104b 100644 --- a/src/main/acp/acp-background-task-timeline.ts +++ b/src/main/acp/acp-background-task-timeline.ts @@ -29,13 +29,13 @@ export class AcpBackgroundTaskTimeline { history = false ): ProviderTimelineEvent[] { return updates.map((update) => { - const { fallbackLabel, ...fields } = update + const { fallbackLabel, fallbackKind, ...fields } = update const previous = this.snapshots.get(update.taskId) const block: NativeChatBackgroundTaskBlock = { type: 'background-task', - kind: 'unknown', ...previous, ...fields, + kind: update.kind ?? previous?.kind ?? fallbackKind ?? 'unknown', label: update.label ?? previous?.label ?? fallbackLabel ?? update.taskId, // A historical launch proves no current liveness; explicit outcomes still settle it. state: diff --git a/src/main/acp/acp-context-usage.ts b/src/main/acp/acp-context-usage.ts index f3f260df741c..d36c1d2d7eac 100644 --- a/src/main/acp/acp-context-usage.ts +++ b/src/main/acp/acp-context-usage.ts @@ -5,7 +5,7 @@ import type { ProviderTimelineJoin } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect } from './acp-dialects/acp-dialect' -import type { UsageUpdate } from './generated/acp-protocol.generated' +import type { SessionUpdate, UsageUpdate } from './generated/acp-protocol.generated' export class AcpContextTimeline { private window?: { tokens: number; capturedAt: number } @@ -30,6 +30,20 @@ export class AcpContextTimeline { } ] } + + /** History that is not adopted is dropped except what it says about the context window. */ + history( + update: SessionUpdate | undefined, + usage: AgentSessionContextUsage | undefined, + at: number, + join: ProviderTimelineJoin + ): ProviderTimelineEvent[] { + const events = usage ? this.update(usage, join) : [] + if (update?.sessionUpdate === 'usage_update') { + events.push({ type: 'context.usage', usage: acpWindowUsage(update, at), join }) + } + return events + } } export function acpWindowUsage(update: UsageUpdate, at: number): AgentSessionContextUsage { diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 9fed58f93789..5a7facd99a69 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -14,6 +14,8 @@ export type AcpBackgroundTaskUpdate = Pick> & { /** Used only until a provider description is known. */ fallbackLabel?: string + /** Used only until a frame names the task's kind. */ + fallbackKind?: NativeChatBackgroundTaskBlock['kind'] } export type AcpDialectNotification = @@ -24,7 +26,9 @@ export type AcpDialectNotification = replay?: boolean at?: number started?: boolean - end?: { stopReason: string; durationMs?: number } + end?: { stopReason: string; durationMs?: number; failureDetail?: string } + /** The provider's own words for why `turn` failed, sent apart from its end. */ + failureDetail?: string usage?: AgentSessionContextUsage backgroundTasks?: AcpBackgroundTaskUpdate[] } @@ -40,6 +44,8 @@ export type AcpDialect = { notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined contextWindow?(models: unknown): number | undefined request?(method: string, params: unknown): AcpRequestPresentation | undefined + /** The provider's words in a `session/prompt` error answer, when its message is generic. */ + promptErrorDetail?(error: unknown): string | undefined } export const GENERIC_ACP_DIALECT: AcpDialect = {} diff --git a/src/main/acp/acp-dialects/grok-background-tasks.ts b/src/main/acp/acp-dialects/grok-background-tasks.ts index 3726c8b25327..3f7bd3f32f64 100644 --- a/src/main/acp/acp-dialects/grok-background-tasks.ts +++ b/src/main/acp/acp-dialects/grok-background-tasks.ts @@ -76,30 +76,34 @@ function snapshot( state: NativeChatBackgroundTaskBlock['state'] ): AcpBackgroundTaskUpdate { const kind = task.kind ?? task.task_type - const monitor = kind === 'monitor' || task.monitor_description != null + // Grok's output reads name a monitor only by this command prefix. + const monitor = + kind === 'monitor' || + task.monitor_description != null || + task.command?.startsWith('[monitor') === true const label = task.monitor_description?.trim() || task.description?.trim() const fallbackLabel = task.display_command?.trim() || task.command?.trim() + const settled = isSettledBackgroundTaskState(state) return { taskId: task.task_id, state, ...(monitor ? { kind: 'monitor' as const } - : kind !== undefined || task.command !== undefined + : kind !== undefined ? { kind: kind === 'bash' || kind === 'shell' || task.command !== undefined ? ('command' as const) - : normalizeBackgroundTaskKind(kind ?? 'unknown') + : normalizeBackgroundTaskKind(kind) } - : {}), + : task.command !== undefined + ? { fallbackKind: 'command' as const } + : {}), ...(label ? { label } : fallbackLabel ? { fallbackLabel } : {}), ...(task.output_file === undefined ? {} : { outputFile: task.output_file }), - ...(task.summary === undefined && !isSettledBackgroundTaskState(state) - ? {} - : { summary: task.summary ?? '' }), - ...(task.error === undefined && !isSettledBackgroundTaskState(state) - ? {} - : { error: task.error ?? '' }) + // A running task's summary is Grok's "Background task started", not the task's result. + ...(settled ? { summary: task.summary ?? '' } : {}), + ...(task.error === undefined && !settled ? {} : { error: task.error ?? '' }) } } diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index 51a7bf7f59cc..87569e9d3ed5 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -14,8 +14,27 @@ const completionSchema = z.looseObject({ sessionUpdate: z.literal('turn_completed'), prompt_id: z.string(), stop_reason: z.string(), + agent_result: z.string().nullish(), elapsed_ms: tokenCount.optional() }) +const retrySchema = z.looseObject({ + sessionUpdate: z.literal('retry_state'), + type: z.string(), + message: z.string().optional() +}) +const promptCompleteSchema = z.looseObject({ + sessionId: z.string(), + promptId: z.string(), + stopReason: z.string(), + agentResult: z.string().nullish() +}) +const promptErrorSchema = z.looseObject({ data: z.looseObject({ message: z.string() }) }) +/** Grok writes its result text as the failure's reason only for these ends. */ +const FAILED_STOP_REASONS = ['error', 'rate_limit'] + +function failureDetail(stopReason: string, result: string | null | undefined): string | undefined { + return FAILED_STOP_REASONS.includes(stopReason) && result?.trim() ? result : undefined +} const responseSchema = z.object({ sessionUpdate: z.literal('response_completed'), usage: z.object({ @@ -50,6 +69,17 @@ function contextWindow(models: unknown): number | undefined { ) } +function completionEnd( + completion: z.infer +): NonNullable['end']> { + const detail = failureDetail(completion.stop_reason, completion.agent_result) + return { + stopReason: completion.stop_reason, + durationMs: completion.elapsed_ms, + ...(detail ? { failureDetail: detail } : {}) + } +} + function notification( method: string, params: unknown, @@ -66,6 +96,16 @@ function notification( ? { disposition: 'ignore' } : { disposition: 'map', usage: { window: { tokens, capturedAt: at } } } } + if (canonical === 'x.ai/session/prompt_complete') { + // A copy of the turn's end; only its failure reason is read, in case the end carried none. + const parsed = promptCompleteSchema.safeParse(params) + const detail = parsed.success + ? failureDetail(parsed.data.stopReason, parsed.data.agentResult) + : undefined + return parsed.success && detail + ? { disposition: 'map', turn: parsed.data.promptId, failureDetail: detail } + : { disposition: 'ignore' } + } if (canonical === 'x.ai/queue/changed') { const parsed = queueSchema.safeParse(params) return parsed.success && parsed.data.runningPromptId @@ -86,7 +126,13 @@ function notification( const meta = turnMetaSchema.safeParse(envelope._meta) const completion = completionSchema.safeParse(envelope.update) const response = responseSchema.safeParse(envelope.update) - if (method !== 'session/update' && !completion.success && !response.success) { + // Only a retry Grok gave up on is read; it names no turn, so it joins the one running. + const retry = retrySchema.safeParse(envelope.update) + const retryFailure = + retry.success && retry.data.type === 'failed' && retry.data.message?.trim() + ? retry.data.message + : undefined + if (method !== 'session/update' && !completion.success && !response.success && !retryFailure) { return { disposition: 'ignore' } } const turn = completion.success @@ -99,11 +145,8 @@ function notification( ...(turn === undefined ? {} : { turn }), ...(meta.success && meta.data.turnStartMs !== undefined ? { at: meta.data.turnStartMs } : {}), replay: envelope._meta?.isReplay === true, - ...(completion.success - ? { - end: { stopReason: completion.data.stop_reason, durationMs: completion.data.elapsed_ms } - } - : {}), + ...(completion.success ? { end: completionEnd(completion.data) } : {}), + ...(retryFailure ? { failureDetail: retryFailure } : {}), ...(response.success ? { usage: { @@ -132,5 +175,6 @@ export const GROK_ACP_DIALECT: AcpDialect = { request: grokRequest, toolBackgroundTasks: grokToolBackgroundTasks, notification, - contextWindow + contextWindow, + promptErrorDetail: (error) => promptErrorSchema.safeParse(error).data?.data.message } diff --git a/src/main/acp/acp-prompt-turns.ts b/src/main/acp/acp-prompt-turns.ts index 6dd867688c87..c9fe584dcabe 100644 --- a/src/main/acp/acp-prompt-turns.ts +++ b/src/main/acp/acp-prompt-turns.ts @@ -11,6 +11,8 @@ export type AcpPromptTurn = { /** An injected identity is known before any provider output arrives. */ export class AcpPromptTurns { current?: AcpPromptTurn + /** The last prompt that ended, so its answer can still add the failure reason the end lacked. */ + last?: AcpPromptTurn constructor( private readonly sessionId: string, @@ -26,6 +28,11 @@ export class AcpPromptTurns { return { promptId: turn, events: this.injected ? [] : this.start(turn, at) } } + finish(): void { + this.last = this.current + this.current = undefined + } + start(turn: string, at: number): ProviderTimelineEvent[] { const prompt = this.current if (prompt?.turn !== turn || prompt.opened) { diff --git a/src/main/acp/acp-timeline-background-task-words.test.ts b/src/main/acp/acp-timeline-background-task-words.test.ts new file mode 100644 index 000000000000..72a835a42471 --- /dev/null +++ b/src/main/acp/acp-timeline-background-task-words.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' + +afterEach(closeProviderTimelineRigs) + +type Fixture = Awaited> + +async function taskBlocks(fixture: Fixture) { + return (await fixture.rig.rows()).flatMap((row) => + row.body.kind === 'message' ? row.body.blocks.filter(isBackgroundTaskBlock) : [] + ) +} + +function notify(fixture: Fixture, method: string, update: Record) { + fixture.apply( + fixture + .lane() + .notification(method, { sessionId: 'session-1', _meta: { promptId: 'turn-1' }, update }, 2000) + ) +} + +describe('what a background task row says', () => { + it('describes a running recorded command by its description, not the start summary', async () => { + const fixture = await openAcpFixtureRig() + const frames = await readAcpFixture('s6-background') + const completion = frames.findIndex((frame) => frame.message.method === '_x.ai/task_completed') + await fixture.feed(frames.slice(0, completion)) + const rows = (await fixture.rig.rows()).filter( + (row) => row.body.kind === 'message' && row.body.blocks.some(isBackgroundTaskBlock) + ) + expect(rows).toHaveLength(1) + expect(rows[0]?.body).toMatchObject({ + blocks: [ + { type: 'text', text: 'Started background command "Sleep then write bg-marker.txt"' }, + expect.objectContaining({ type: 'background-task', state: 'working' }) + ] + }) + const [task] = await taskBlocks(fixture) + expect(task?.summary).toBeUndefined() + expect(JSON.stringify(rows[0]?.body)).not.toContain('Background task') + }) + + it('keeps a monitor a monitor when the agent reads its output', async () => { + const fixture = await openAcpFixtureRig() + notify(fixture, '_x.ai/task_backgrounded', { + sessionUpdate: 'task_backgrounded', + task_id: 'watch-1', + command: 'for i in 1 2 3; do echo tick $i; sleep 8; done', + monitor_description: 'Watch three tick echoes' + }) + notify(fixture, '_x.ai/task_completed', { + sessionUpdate: 'task_completed', + task_snapshot: { task_id: 'watch-1', kind: 'monitor', exit_code: 0 } + }) + for (const [index, command] of [ + '[monitor] Watch three tick echoes', + 'for i in 1 2 3; do echo tick $i; sleep 8; done' + ].entries()) { + notify(fixture, 'session/update', { + sessionUpdate: 'tool_call_update', + toolCallId: `read-${index}`, + status: 'completed', + rawOutput: { + type: 'TaskOutput', + Result: { task_id: 'watch-1', command, status: 'completed', exit_code: 0 } + } + }) + } + const [task] = await taskBlocks(fixture) + expect(task).toMatchObject({ kind: 'monitor', state: 'done' }) + expect(task && backgroundTaskFallbackText(task)).toBe( + 'Background monitor "Watch three tick echoes" finished' + ) + }) + + it('reads a monitor from its output command alone, and a named kind still wins', async () => { + const fixture = await openAcpFixtureRig() + notify(fixture, 'session/update', { + sessionUpdate: 'tool_call_update', + toolCallId: 'read-1', + status: 'completed', + rawOutput: { + type: 'TaskOutput', + Result: { task_id: 'watch-2', command: '[monitor] Tail the log', status: 'running' } + } + }) + expect((await taskBlocks(fixture))[0]?.kind).toBe('monitor') + notify(fixture, '_x.ai/task_completed', { + sessionUpdate: 'task_completed', + task_snapshot: { task_id: 'watch-2', task_type: 'bash', command: 'tail -f log', exit_code: 0 } + }) + expect((await taskBlocks(fixture))[0]?.kind).toBe('command') + }) +}) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 7fabbb48000f..ac09b6b276c7 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -1,25 +1,20 @@ import { z } from 'zod' import { BoundedMap } from '../../shared/bounded-map' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' -import { - acpNotificationEnvelopeSchema, - AcpContextTimeline, - acpWindowUsage -} from './acp-context-usage' +import { acpNotificationEnvelopeSchema, AcpContextTimeline } from './acp-context-usage' import { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' import { GENERIC_ACP_DIALECT, type AcpDialect, - type AcpDialectNotification, type AcpRequestPresentation } from './acp-dialects/acp-dialect' import { AcpHistoryAdoption } from './acp-history-adoption' import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' import type { AcpSessionEvent } from './acp-session-runtime' import { translateAcpRequest } from './acp-timeline-requests' -export { acpTurnEnd } from './acp-prompt-turns' import { acpSessionUpdate } from './acp-session-update' import { AcpToolTimeline } from './acp-tool-timeline' +import { AcpTurnFailures, acpPromptErrorDetail, acpStopReasonFailed } from './acp-turn-failures' import { AcpTurnMessages } from './acp-turn-messages' import { SessionNotificationSchema, @@ -27,6 +22,8 @@ import { type SessionNotification } from './generated/acp-protocol.generated' +export { acpTurnEnd } from './acp-prompt-turns' + const requestSessionSchema = z.object({ sessionId: z.string() }) const SUBSTANTIVE_UPDATES = [ 'user_message_chunk', @@ -55,12 +52,14 @@ export class AcpTimelineTranslator { private readonly backgroundTasks: AcpBackgroundTaskTimeline private readonly messages = new AcpTurnMessages() private readonly started = new BoundedMap({ maxEntries: 128 }) + private readonly failures: AcpTurnFailures private load?: { adoption?: AcpHistoryAdoption } private readonly context = new AcpContextTimeline() private activeTurn?: string constructor(private readonly options: AcpTimelineTranslatorOptions) { this.dialect = options.dialect ?? GENERIC_ACP_DIALECT + this.failures = new AcpTurnFailures(options.sessionId) this.backgroundTasks = new AcpBackgroundTaskTimeline((callId) => this.tools.turn(callId)) this.prompts = new AcpPromptTurns( options.sessionId, @@ -87,23 +86,30 @@ export class AcpTimelineTranslator { return this.finishPrompt(clientMessageId, result.stopReason, at) } - promptFailed(clientMessageId: string, _error: unknown, at: number): ProviderTimelineEvent[] { - return this.finishPrompt(clientMessageId, 'error', at) + promptFailed(clientMessageId: string, error: unknown, at: number): ProviderTimelineEvent[] { + const detail = acpPromptErrorDetail(this.dialect, error) + const ended = this.prompts.last + if (this.prompts.current?.clientMessageId !== clientMessageId) { + // The provider already ended this turn; its answer may carry the only copy of the reason. + return ended?.clientMessageId === clientMessageId && this.failures.has(ended.turn) + ? this.failures.row(ended.turn, detail) + : [] + } + return this.finishPrompt(clientMessageId, 'error', at, detail) } private finishPrompt( clientMessageId: string, stopReason: string, - at: number + at: number, + failureDetail?: string ): ProviderTimelineEvent[] { const prompt = this.prompts.current if (prompt?.clientMessageId !== clientMessageId) { return [] } - const events = this.prompts.start(prompt.turn, at) - events.push(acpTurnEnd(prompt.turn, stopReason, at, prompt.durationMs)) - this.end(prompt.turn) - this.prompts.current = undefined + const events = this.start(prompt.turn, at) + events.push(...this.endTurn(prompt.turn, stopReason, at, prompt.durationMs, failureDetail)) return events } @@ -145,11 +151,10 @@ export class AcpTimelineTranslator { if (session.success && session.data.sessionId !== this.options.sessionId) { return [] } - const interpreted = this.dialect.notification?.(method, params, at) - if (interpreted?.disposition === 'ignore') { + const extension = this.dialect.notification?.(method, params, at) + if (extension?.disposition === 'ignore') { return [] } - const extension = interpreted if (method !== 'session/update' && !extension) { return [] } @@ -162,7 +167,7 @@ export class AcpTimelineTranslator { const history = markedReplay || (extension?.replay === undefined && this.load !== undefined) const adoption = history ? this.load?.adoption : undefined if (history && !adoption) { - return this.historyUsage(update, extension, at) + return this.context.history(update, extension?.usage, at, { thread: this.options.sessionId }) } if (adoption && update?.sessionUpdate === 'user_message_chunk') { return this.historyUser(adoption, update, at) @@ -193,18 +198,18 @@ export class AcpTimelineTranslator { if (extension?.usage) { events.push(...this.context.update(extension.usage, join)) } - if (extension?.end && turn) { + if (extension?.failureDetail && turn) { + events.push(...this.failures.row(turn, extension.failureDetail)) + } + const end = extension?.end + if (end && turn) { if ( this.prompts.current?.turn === turn && - ['end_turn', 'cancelled'].includes(extension.end.stopReason) + ['end_turn', 'cancelled'].includes(end.stopReason) ) { - this.prompts.current.durationMs = extension.end.durationMs + this.prompts.current.durationMs = end.durationMs } else { - events.push(acpTurnEnd(turn, extension.end.stopReason, at, extension.end.durationMs)) - this.end(turn) - if (this.prompts.current?.turn === turn) { - this.prompts.current = undefined - } + events.push(...this.endTurn(turn, end.stopReason, at, end.durationMs, end.failureDetail)) } return events } @@ -243,16 +248,19 @@ export class AcpTimelineTranslator { }) } - /** History that is not adopted is dropped except what it says about the context window. */ - private historyUsage( - update: SessionNotification['update'] | undefined, - extension: Extract | undefined, - at: number + /** A failed end writes the reason row before the end, so it joins the turn while open. */ + private endTurn( + turn: string, + stopReason: string, + at: number, + durationMs: number | undefined, + failureDetail: string | undefined ): ProviderTimelineEvent[] { - const join = { thread: this.options.sessionId } - const events = extension?.usage ? this.context.update(extension.usage, join) : [] - if (update?.sessionUpdate === 'usage_update') { - events.push({ type: 'context.usage', usage: acpWindowUsage(update, at), join }) + const events = acpStopReasonFailed(stopReason) ? this.failures.row(turn, failureDetail) : [] + events.push(acpTurnEnd(turn, stopReason, at, durationMs)) + this.end(turn) + if (this.prompts.current?.turn === turn) { + this.prompts.finish() } return events } @@ -298,6 +306,8 @@ export class AcpTimelineTranslator { private start(turn: string, at: number): ProviderTimelineEvent[] { if (turn === this.prompts.current?.turn) { const events = this.prompts.start(turn, at) + // Marked so a late frame for this prompt after its end neither reopens nor retargets it. + this.started.set(turn, true) this.activeTurn = turn return events } diff --git a/src/main/acp/acp-timeline-turn-failures.test.ts b/src/main/acp/acp-timeline-turn-failures.test.ts new file mode 100644 index 000000000000..a164daef67ab --- /dev/null +++ b/src/main/acp/acp-timeline-turn-failures.test.ts @@ -0,0 +1,232 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { + closeProviderTimelineRigs, + messageText, + providerTurnItemId +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' + +afterEach(closeProviderTimelineRigs) + +// The shape Grok sends for a prompt its API refused, with the reason scrubbed to a placeholder. +const REASON = 'API error (status 400 Bad Request): invalid_request_error: placeholder reason' +const session = { sessionId: 'session-1' } +const queued = (promptId: string) => ({ ...session, entries: [], runningPromptId: promptId }) +const grokUpdate = (update: Record) => ({ + ...session, + update, + _meta: { agentTimestampMs: 1 } +}) +const retryFailed = grokUpdate({ + sessionUpdate: 'retry_state', + type: 'failed', + error_type: 'api', + message: REASON +}) +const ended = (promptId: string, stopReason: string, agentResult?: string) => + grokUpdate({ + sessionUpdate: 'turn_completed', + prompt_id: promptId, + stop_reason: stopReason, + ...(agentResult === undefined ? {} : { agent_result: agentResult }), + elapsed_ms: 729 + }) +const promptComplete = (promptId: string, stopReason: string, agentResult: string | null) => ({ + ...session, + promptId, + stopReason, + agentResult +}) +const rpcError = { code: -32603, message: 'Internal error', data: { message: REASON } } + +function statusRows(rows: AgentJournalRenderItem[]) { + return rows.flatMap((row) => (row.body.kind === 'status' ? [{ row, body: row.body }] : [])) +} + +describe('a failed ACP turn says why', () => { + it('writes the reason once when Grok sends it four times, in the failed turn', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply(lane.notification('_x.ai/session_notification', retryFailed, 1002)) + f.apply( + lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error', REASON), 1003) + ) + expect(statusRows(await f.rig.rows())[0]?.body.failure?.detail?.text).toBe(REASON) + f.apply( + lane.notification( + '_x.ai/session/prompt_complete', + promptComplete(prompt.promptId, 'error', REASON), + 1004 + ) + ) + f.apply(lane.promptFailed('c1', rpcError, 1005)) + const next = lane.openPrompt('c2', 1006) + f.apply(lane.notification('_x.ai/queue/changed', queued(next.promptId), 1007)) + f.apply( + lane.notification( + 'session/update', + { + ...session, + _meta: { promptId: next.promptId }, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'ok' } } + }, + 1008 + ) + ) + f.apply(lane.notification('_x.ai/session_notification', ended(next.promptId, 'end_turn'), 1009)) + f.apply(lane.promptResult('c2', { stopReason: 'end_turn' }, 1010)) + const rows = await f.rig.rows() + const failures = statusRows(rows) + expect(failures).toHaveLength(1) + expect(failures[0]?.body).toMatchObject({ + tone: 'error', + text: `The provider did not accept this message: ${REASON}.`, + failure: { kind: 'providerRejected', detail: { text: REASON, audience: 'person' } } + }) + const turns = await f.rig.turns() + expect(turns.map((turn) => [turn.state, turn.outcome])).toEqual([ + ['completed', 'failure'], + ['completed', 'success'] + ]) + expect(failures[0]?.row.turnScope).toEqual({ + kind: 'turn', + turnItemId: providerTurnItemId(prompt.promptId) + }) + expect( + rows.filter((row) => row.body.kind === 'message').map((row) => messageText(row.body)) + ).toEqual(['ok']) + }) + + it('takes the reason from the prompt error answer when no frame carried it', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply(lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error'), 1002)) + expect(statusRows(await f.rig.rows())[0]?.body.text).toBe( + 'The provider did not accept this message.' + ) + f.apply(lane.promptFailed('c1', rpcError, 1003)) + f.apply(lane.promptFailed('c1', rpcError, 1004)) + const failures = statusRows(await f.rig.rows()) + expect(failures).toHaveLength(1) + expect(failures[0]?.body.failure?.detail?.text).toBe(REASON) + }) + + it.each(['retry_state', 'turn_completed', 'prompt_complete', 'prompt error answer'] as const)( + 'reads the reason when only the %s carries it', + async (source) => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + const reason = (carrier: typeof source) => (carrier === source ? REASON : undefined) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + if (source === 'retry_state') { + f.apply(lane.notification('_x.ai/session_notification', retryFailed, 1002)) + } + f.apply( + lane.notification( + '_x.ai/session_notification', + ended(prompt.promptId, 'error', reason('turn_completed')), + 1003 + ) + ) + f.apply( + lane.notification( + '_x.ai/session/prompt_complete', + promptComplete(prompt.promptId, 'error', reason('prompt_complete') ?? null), + 1004 + ) + ) + f.apply( + lane.promptFailed( + 'c1', + source === 'prompt error answer' ? rpcError : { code: -32603, message: '' }, + 1005 + ) + ) + const failures = statusRows(await f.rig.rows()) + expect(failures.map((failure) => failure.body.failure?.detail?.text)).toEqual([REASON]) + } + ) + + it('keeps the reason when a later copy carries none', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply(lane.notification('_x.ai/session_notification', retryFailed, 1002)) + f.apply(lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error'), 1003)) + f.apply(lane.promptFailed('c1', { code: -32603, message: 'Internal error' }, 1004)) + const failures = statusRows(await f.rig.rows()) + expect(failures.map((failure) => failure.body.failure?.detail?.text)).toEqual([REASON]) + }) + + it('records a rate-limited turn in the same words, without inventing a reason', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply( + lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'rate_limit'), 1002) + ) + f.apply( + lane.notification( + '_x.ai/session/prompt_complete', + promptComplete(prompt.promptId, 'rate_limit', null), + 1003 + ) + ) + const failures = statusRows(await f.rig.rows()) + expect(failures.map((failure) => failure.body.failure)).toEqual([{ kind: 'providerRejected' }]) + expect((await f.rig.turns())[0]?.outcome).toBe('failure') + }) + + it('writes no failure row for a turn the provider ended on purpose', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply( + lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'max_tokens'), 1002) + ) + f.apply(lane.promptResult('c1', { stopReason: 'max_tokens' }, 1003)) + expect(statusRows(await f.rig.rows())).toEqual([]) + }) + + it('does not reopen or retarget an ended prompt for its late frame', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply( + lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'end_turn'), 1002) + ) + f.apply(lane.promptResult('c1', { stopReason: 'end_turn' }, 1003)) + const late = lane.notification( + 'session/update', + { + ...session, + _meta: { promptId: prompt.promptId }, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'late' } } + }, + 1004 + ) + expect(late.filter((event) => event.type === 'turn.open')).toEqual([]) + const promptless = lane.notification( + 'session/update', + { + ...session, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'x' } } + }, + 1005 + ) + expect(promptless.find((event) => event.type === 'text.delta')).toMatchObject({ + join: { thread: 'session-1' } + }) + expect(promptless.find((event) => event.type === 'text.delta')?.join).not.toHaveProperty('turn') + }) +}) diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts index 1a4675ed4330..28762bc4646d 100644 --- a/src/main/acp/acp-tool-timeline.ts +++ b/src/main/acp/acp-tool-timeline.ts @@ -77,11 +77,13 @@ type ToolSnapshot = { body: AgentJournalToolCallItem; turn?: string } /** Holds the snapshots that fill ACP's partial tool updates, settled ones evicted first. Its bound * is the assembler's open budget, which counts each running tool at no fewer bytes, so a running * tool is evicted only after the assembler refused one past that budget, which ends the session. - * An update for an evicted settled tool is dropped by the assembler as settled. */ + * An update for an evicted settled tool is dropped by the assembler as settled. + * A tool's turn is held once: on its snapshot while that lives, then in `turns`. */ export class AcpToolTimeline { private readonly tools = new Map() private bytes = 0 - /** Outlives the turn, so a later task or request still lands beside the tool that started it. */ + /** Turns of tools whose snapshot is gone, so a later task or request still lands beside the tool + * that started it. */ private readonly turns = new BoundedMap({ maxEntries: MAX_PROVIDER_TIMELINE_OPEN_ENTRIES }) @@ -122,14 +124,9 @@ export class AcpToolTimeline { : {} : { output: boundPayload(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) }) } - const snapshot: ToolSnapshot = { - body, - ...(join.turn === undefined ? {} : { turn: join.turn }) - } - this.remember(update.toolCallId, snapshot) - if (join.turn !== undefined) { - this.turns.set(update.toolCallId, join.turn) - } + const turn = join.turn ?? this.turn(update.toolCallId) + this.turns.delete(update.toolCallId) + this.remember(update.toolCallId, { body, ...(turn === undefined ? {} : { turn }) }) const events: ProviderTimelineEvent[] = [ { type: state === 'running' ? (previous ? 'item.update' : 'item.open') : 'item.close', @@ -173,11 +170,18 @@ export class AcpToolTimeline { for (const [key, snapshot] of this.tools) { if (snapshot.turn === turn) { this.bytes -= this.size(key, snapshot) - this.tools.delete(key) + this.retire(key, snapshot) } } } + private retire(key: string, snapshot: ToolSnapshot): void { + this.tools.delete(key) + if (snapshot.turn !== undefined) { + this.turns.set(key, snapshot.turn) + } + } + private fits(count: number, bytes: number): boolean { return count <= MAX_PROVIDER_TIMELINE_OPEN_ENTRIES && bytes <= MAX_PROVIDER_TIMELINE_OPEN_BYTES } @@ -191,8 +195,8 @@ export class AcpToolTimeline { if (this.size(key, snapshot) > MAX_PROVIDER_TIMELINE_OPEN_BYTES) { if (previous) { this.bytes -= this.size(key, previous) - this.tools.delete(key) } + this.retire(key, snapshot) return } let bytes = this.bytes - (previous ? this.size(key, previous) : 0) + this.size(key, snapshot) @@ -219,7 +223,10 @@ export class AcpToolTimeline { } } for (const candidate of evicted) { - this.tools.delete(candidate) + const stored = this.tools.get(candidate) + if (stored) { + this.retire(candidate, stored) + } } this.tools.delete(key) this.tools.set(key, snapshot) diff --git a/src/main/acp/acp-turn-failures.ts b/src/main/acp/acp-turn-failures.ts new file mode 100644 index 000000000000..d4e029f652d2 --- /dev/null +++ b/src/main/acp/acp-turn-failures.ts @@ -0,0 +1,57 @@ +import { z } from 'zod' +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { BoundedMap } from '../../shared/bounded-map' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpDialect } from './acp-dialects/acp-dialect' + +const promptErrorSchema = z.looseObject({ message: z.string() }) +/** Ends the provider failed, rather than ones it chose (a refusal, a token limit). */ +const FAILED_STOP_REASONS = ['error', 'rate_limit'] + +export function acpStopReasonFailed(stopReason: string): boolean { + return FAILED_STOP_REASONS.includes(stopReason) +} + +/** The provider's words in its error answer to `session/prompt`. */ +export function acpPromptErrorDetail(dialect: AcpDialect, error: unknown): string | undefined { + return dialect.promptErrorDetail?.(error) ?? promptErrorSchema.safeParse(error).data?.message +} + +/** One status row per failed turn, in Orca's rejection words with the provider's reason. Providers + * send that reason several times (beside the end, after it, in the prompt's error answer), so a + * later copy only adds a reason the row still lacks. */ +export class AcpTurnFailures { + /** The reason each failed turn's row holds; '' for none yet. */ + private readonly rows = new BoundedMap({ maxEntries: 128 }) + + constructor(private readonly sessionId: string) {} + + has(turn: string): boolean { + return this.rows.has(turn) + } + + row(turn: string, text: string | undefined): ProviderTimelineEvent[] { + const written = this.rows.peek(turn) + const detail = text === undefined ? undefined : providerDiagnostic(text, 'person') + if (written !== undefined && (written !== '' || !detail)) { + return [] + } + this.rows.set(turn, detail?.text ?? '') + return [ + { + type: 'item.update', + item: `turn-failure:${turn}`, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', detail ? { detail } : {}), + { surface: 'row' } + ) + }, + join: { thread: this.sessionId, turn } + } + ] + } +} From e88bbf0bb11ea9f87a83d226de255e0b338f11ad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:59:35 -0700 Subject: [PATCH 140/269] Read a monitor from Grok's exact output prefix --- src/main/acp/acp-dialects/grok-background-tasks.ts | 5 +++-- src/main/acp/acp-timeline-background-task-words.test.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/acp/acp-dialects/grok-background-tasks.ts b/src/main/acp/acp-dialects/grok-background-tasks.ts index 3f7bd3f32f64..415a765840e4 100644 --- a/src/main/acp/acp-dialects/grok-background-tasks.ts +++ b/src/main/acp/acp-dialects/grok-background-tasks.ts @@ -43,6 +43,8 @@ const resultOutputSchema = z.object({ MultiResult: z.object({ results: z.array(taskResultSchema) }).optional() }) type GrokTask = z.infer +/** Grok's output reads name a monitor only by this command prefix. */ +const MONITOR_COMMAND = /^\[monitor[:\]]/ function completedState(task: GrokTask): NativeChatBackgroundTaskBlock['state'] { return task.explicitly_killed @@ -76,11 +78,10 @@ function snapshot( state: NativeChatBackgroundTaskBlock['state'] ): AcpBackgroundTaskUpdate { const kind = task.kind ?? task.task_type - // Grok's output reads name a monitor only by this command prefix. const monitor = kind === 'monitor' || task.monitor_description != null || - task.command?.startsWith('[monitor') === true + MONITOR_COMMAND.test(task.command ?? '') const label = task.monitor_description?.trim() || task.description?.trim() const fallbackLabel = task.display_command?.trim() || task.command?.trim() const settled = isSettledBackgroundTaskState(state) diff --git a/src/main/acp/acp-timeline-background-task-words.test.ts b/src/main/acp/acp-timeline-background-task-words.test.ts index 72a835a42471..c167d5c35927 100644 --- a/src/main/acp/acp-timeline-background-task-words.test.ts +++ b/src/main/acp/acp-timeline-background-task-words.test.ts @@ -84,7 +84,7 @@ describe('what a background task row says', () => { status: 'completed', rawOutput: { type: 'TaskOutput', - Result: { task_id: 'watch-2', command: '[monitor] Tail the log', status: 'running' } + Result: { task_id: 'watch-2', command: '[monitor:tail] Tail the log', status: 'running' } } }) expect((await taskBlocks(fixture))[0]?.kind).toBe('monitor') From ad7678cc9724ab40c3adf69dcbca63609a651c37 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:15:27 -0700 Subject: [PATCH 141/269] Word a failed Grok turn in Grok's own text, not as a refused message A turn that started and then failed was told "The provider did not accept this message", Orca's sentence for a message refused before its turn. The row now reads as a Codex turn-ending error does: an error status row with the provider's own words. With no words, the dialect names the failure ("Grok ended this turn with an error." / "Grok usage limit reached."), else the agent's display name does. --- src/main/acp/acp-dialects/acp-dialect.ts | 2 ++ src/main/acp/acp-dialects/grok-dialect.ts | 6 +++- src/main/acp/acp-timeline-generic.test.ts | 28 +++++++++++++++- src/main/acp/acp-timeline-translator.ts | 8 +++-- .../acp/acp-timeline-turn-failures.test.ts | 29 ++++++++-------- src/main/acp/acp-turn-failures.ts | 33 ++++++++++++------- 6 files changed, 75 insertions(+), 31 deletions(-) diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 5a7facd99a69..4697189a20ee 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -46,6 +46,8 @@ export type AcpDialect = { request?(method: string, params: unknown): AcpRequestPresentation | undefined /** The provider's words in a `session/prompt` error answer, when its message is generic. */ promptErrorDetail?(error: unknown): string | undefined + /** The row for a failed turn the provider gave no words for. */ + failedTurnText?(stopReason: string): string } export const GENERIC_ACP_DIALECT: AcpDialect = {} diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index 87569e9d3ed5..b47f899f30ee 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -176,5 +176,9 @@ export const GROK_ACP_DIALECT: AcpDialect = { toolBackgroundTasks: grokToolBackgroundTasks, notification, contextWindow, - promptErrorDetail: (error) => promptErrorSchema.safeParse(error).data?.data.message + promptErrorDetail: (error) => promptErrorSchema.safeParse(error).data?.data.message, + failedTurnText: (stopReason) => + stopReason === 'rate_limit' + ? 'Grok usage limit reached.' + : 'Grok ended this turn with an error.' } diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts index a18c67b03b04..4a041b3ad3ae 100644 --- a/src/main/acp/acp-timeline-generic.test.ts +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -11,7 +11,7 @@ import { AcpTimelineTranslator, acpTurnEnd } from './acp-timeline-translator' afterEach(closeProviderTimelineRigs) -async function genericRig(options: { adopt?: boolean } = {}) { +async function genericRig(options: { adopt?: boolean; agentName?: string } = {}) { const rig = await openProviderTimelineRig() const translator = new AcpTimelineTranslator({ sessionId: 'provider-1', ...options }) const apply = (events: ProviderTimelineEvent[]) => { @@ -167,6 +167,32 @@ describe('generic ACP translation', () => { } ) + it.each([ + [undefined, 'The agent ended this turn with an error.'], + ['Agent X', 'Agent X ended this turn with an error.'] + ])( + "writes a failed turn row in the agent's words, or names the agent (%s) without any", + async (agentName, fallback) => { + const { rig, translator, apply } = await genericRig(agentName ? { agentName } : {}) + apply(translator.openPrompt('send-1', 1000).events) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1100)) + apply(translator.openPrompt('send-2', 1200).events) + apply(translator.promptFailed('send-2', { code: -32603, message: 'Upstream failed' }, 1300)) + apply(translator.openPrompt('send-3', 1400).events) + apply(translator.promptFailed('send-3', { code: -32603 }, 1500)) + const rows = (await rig.rows()).filter((row) => row.body.kind === 'status') + expect(rows.map((row) => row.body)).toEqual([ + { kind: 'status', tone: 'error', text: 'Upstream failed' }, + { kind: 'status', tone: 'error', text: fallback } + ]) + expect((await rig.turns()).map((turn) => turn.outcome)).toEqual([ + 'success', + 'failure', + 'failure' + ]) + } + ) + it('keeps unknown verdicts unknown and preserves unknown frames without opening a turn', async () => { const { rig, translator, apply } = await genericRig() expect(acpTurnEnd('turn', 'future_stop', 1200)).not.toHaveProperty('outcome') diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index ac09b6b276c7..2610d1de533f 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -14,7 +14,7 @@ import type { AcpSessionEvent } from './acp-session-runtime' import { translateAcpRequest } from './acp-timeline-requests' import { acpSessionUpdate } from './acp-session-update' import { AcpToolTimeline } from './acp-tool-timeline' -import { AcpTurnFailures, acpPromptErrorDetail, acpStopReasonFailed } from './acp-turn-failures' +import { AcpTurnFailures, acpPromptErrorDetail } from './acp-turn-failures' import { AcpTurnMessages } from './acp-turn-messages' import { SessionNotificationSchema, @@ -41,6 +41,8 @@ export type AcpTimelineTranslatorOptions = { * because the journal is empty (an adopted session). Otherwise history stays out of the * timeline and only its context usage reads on. */ adopt?: boolean + /** The agent's display name, for a failed turn the provider gave no words for. */ + agentName?: string } /** Consumes each frame once; the host retries the returned grammar events. Lives exactly as long @@ -59,7 +61,7 @@ export class AcpTimelineTranslator { constructor(private readonly options: AcpTimelineTranslatorOptions) { this.dialect = options.dialect ?? GENERIC_ACP_DIALECT - this.failures = new AcpTurnFailures(options.sessionId) + this.failures = new AcpTurnFailures(options.sessionId, this.dialect, options.agentName) this.backgroundTasks = new AcpBackgroundTaskTimeline((callId) => this.tools.turn(callId)) this.prompts = new AcpPromptTurns( options.sessionId, @@ -256,7 +258,7 @@ export class AcpTimelineTranslator { durationMs: number | undefined, failureDetail: string | undefined ): ProviderTimelineEvent[] { - const events = acpStopReasonFailed(stopReason) ? this.failures.row(turn, failureDetail) : [] + const events = this.failures.ended(turn, stopReason, failureDetail) events.push(acpTurnEnd(turn, stopReason, at, durationMs)) this.end(turn) if (this.prompts.current?.turn === turn) { diff --git a/src/main/acp/acp-timeline-turn-failures.test.ts b/src/main/acp/acp-timeline-turn-failures.test.ts index a164daef67ab..54e50405582c 100644 --- a/src/main/acp/acp-timeline-turn-failures.test.ts +++ b/src/main/acp/acp-timeline-turn-failures.test.ts @@ -54,7 +54,7 @@ describe('a failed ACP turn says why', () => { f.apply( lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error', REASON), 1003) ) - expect(statusRows(await f.rig.rows())[0]?.body.failure?.detail?.text).toBe(REASON) + expect(statusRows(await f.rig.rows())[0]?.body.text).toBe(REASON) f.apply( lane.notification( '_x.ai/session/prompt_complete', @@ -81,11 +81,8 @@ describe('a failed ACP turn says why', () => { const rows = await f.rig.rows() const failures = statusRows(rows) expect(failures).toHaveLength(1) - expect(failures[0]?.body).toMatchObject({ - tone: 'error', - text: `The provider did not accept this message: ${REASON}.`, - failure: { kind: 'providerRejected', detail: { text: REASON, audience: 'person' } } - }) + // The turn ran, so the row reads as Codex's turn-ending error does: no refusal sentence or fact. + expect(failures[0]?.body).toEqual({ kind: 'status', tone: 'error', text: REASON }) const turns = await f.rig.turns() expect(turns.map((turn) => [turn.state, turn.outcome])).toEqual([ ['completed', 'failure'], @@ -106,14 +103,16 @@ describe('a failed ACP turn says why', () => { const prompt = lane.openPrompt('c1', 1000) f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) f.apply(lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error'), 1002)) - expect(statusRows(await f.rig.rows())[0]?.body.text).toBe( - 'The provider did not accept this message.' - ) + expect(statusRows(await f.rig.rows())[0]?.body).toEqual({ + kind: 'status', + tone: 'error', + text: 'Grok ended this turn with an error.' + }) f.apply(lane.promptFailed('c1', rpcError, 1003)) f.apply(lane.promptFailed('c1', rpcError, 1004)) const failures = statusRows(await f.rig.rows()) expect(failures).toHaveLength(1) - expect(failures[0]?.body.failure?.detail?.text).toBe(REASON) + expect(failures[0]?.body).toEqual({ kind: 'status', tone: 'error', text: REASON }) }) it.each(['retry_state', 'turn_completed', 'prompt_complete', 'prompt error answer'] as const)( @@ -149,7 +148,7 @@ describe('a failed ACP turn says why', () => { ) ) const failures = statusRows(await f.rig.rows()) - expect(failures.map((failure) => failure.body.failure?.detail?.text)).toEqual([REASON]) + expect(failures.map((failure) => failure.body.text)).toEqual([REASON]) } ) @@ -162,10 +161,10 @@ describe('a failed ACP turn says why', () => { f.apply(lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error'), 1003)) f.apply(lane.promptFailed('c1', { code: -32603, message: 'Internal error' }, 1004)) const failures = statusRows(await f.rig.rows()) - expect(failures.map((failure) => failure.body.failure?.detail?.text)).toEqual([REASON]) + expect(failures.map((failure) => failure.body.text)).toEqual([REASON]) }) - it('records a rate-limited turn in the same words, without inventing a reason', async () => { + it('names a rate-limited turn the provider gave no words for, without inventing a reason', async () => { const f = await openAcpFixtureRig() const lane = f.lane() const prompt = lane.openPrompt('c1', 1000) @@ -181,7 +180,9 @@ describe('a failed ACP turn says why', () => { ) ) const failures = statusRows(await f.rig.rows()) - expect(failures.map((failure) => failure.body.failure)).toEqual([{ kind: 'providerRejected' }]) + expect(failures.map((failure) => failure.body)).toEqual([ + { kind: 'status', tone: 'error', text: 'Grok usage limit reached.' } + ]) expect((await f.rig.turns())[0]?.outcome).toBe('failure') }) diff --git a/src/main/acp/acp-turn-failures.ts b/src/main/acp/acp-turn-failures.ts index d4e029f652d2..cc2998945a1d 100644 --- a/src/main/acp/acp-turn-failures.ts +++ b/src/main/acp/acp-turn-failures.ts @@ -1,6 +1,5 @@ import { z } from 'zod' -import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { providerDiagnostic } from '../../shared/agent-session-failure' import { BoundedMap } from '../../shared/bounded-map' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect } from './acp-dialects/acp-dialect' @@ -9,7 +8,7 @@ const promptErrorSchema = z.looseObject({ message: z.string() }) /** Ends the provider failed, rather than ones it chose (a refusal, a token limit). */ const FAILED_STOP_REASONS = ['error', 'rate_limit'] -export function acpStopReasonFailed(stopReason: string): boolean { +function acpStopReasonFailed(stopReason: string): boolean { return FAILED_STOP_REASONS.includes(stopReason) } @@ -18,20 +17,30 @@ export function acpPromptErrorDetail(dialect: AcpDialect, error: unknown): strin return dialect.promptErrorDetail?.(error) ?? promptErrorSchema.safeParse(error).data?.message } -/** One status row per failed turn, in Orca's rejection words with the provider's reason. Providers - * send that reason several times (beside the end, after it, in the prompt's error answer), so a - * later copy only adds a reason the row still lacks. */ +/** One error row per failed turn, in the provider's own words, as a Codex turn-ending error reads: + * the message was accepted and the turn ran, so it is no refusal. Providers send that reason several + * times (beside the end, after it, in the prompt's error answer), so a later copy only adds a + * reason the row still lacks. */ export class AcpTurnFailures { /** The reason each failed turn's row holds; '' for none yet. */ private readonly rows = new BoundedMap({ maxEntries: 128 }) - constructor(private readonly sessionId: string) {} + constructor( + private readonly sessionId: string, + private readonly dialect: AcpDialect, + private readonly agentName: string | undefined + ) {} has(turn: string): boolean { return this.rows.has(turn) } - row(turn: string, text: string | undefined): ProviderTimelineEvent[] { + /** The row an end writes, if it failed. */ + ended(turn: string, stopReason: string, text: string | undefined): ProviderTimelineEvent[] { + return acpStopReasonFailed(stopReason) ? this.row(turn, text, stopReason) : [] + } + + row(turn: string, text: string | undefined, stopReason = 'error'): ProviderTimelineEvent[] { const written = this.rows.peek(turn) const detail = text === undefined ? undefined : providerDiagnostic(text, 'person') if (written !== undefined && (written !== '' || !detail)) { @@ -45,10 +54,10 @@ export class AcpTurnFailures { body: { kind: 'status', tone: 'error', - ...agentSessionFailureWords( - agentSessionFailureFact('providerRejected', detail ? { detail } : {}), - { surface: 'row' } - ) + text: + detail?.text ?? + this.dialect.failedTurnText?.(stopReason) ?? + `${this.agentName ?? 'The agent'} ended this turn with an error.` }, join: { thread: this.sessionId, turn } } From fdba83dc1422c0022c1127771aef0dbcb1158e72 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:23:03 -0700 Subject: [PATCH 142/269] Settle a stopped turn's running call as its turn row ended after a restart too The restart sweep ended every running call by the death evidence alone, so after a person's Stop with no proof the child died the call read failed under a turn that read interrupted. The sweep and the live dead-generation settlement now ask the same rule the assembler does: a call in a turn already settled ends as that row ended; only a turn still running leaves its calls to the evidence. --- .../journal-terminal-settlement.ts | 26 +++++++++- ...vider-timeline-assembler-tool-ends.test.ts | 47 +++++++++++++++++++ .../provider-timeline-settlement.ts | 26 +++------- ...gent-session-dead-generation-settlement.ts | 22 ++++++++- 4 files changed, 97 insertions(+), 24 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts b/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts index efddcfb6e17c..d5787fb6394b 100644 --- a/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts +++ b/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts @@ -2,8 +2,14 @@ import { endedRunningAgentJournalToolCall, type AgentJournalRunningCallEnd } from '../../../shared/agent-journal-tool-call-lifecycle' -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' -import { isRunningAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { + AgentJournalItemBody, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { + isRunningAgentJournalTurn, + readAgentJournalTurn +} from '../../../shared/agent-session-turn-record' import { cancelledJournalPromptBody } from './journal-prompt-body-bounds' /** True while an item is still awaiting the row that settles it, so a sink can @@ -34,3 +40,19 @@ export function terminalAgentJournalBody( } return null } + +/** How a call still running when its turn ends ends: as that turn's journal row ends. A row another + * writer settled first (a person's Stop) stands, so its calls take that row's state, not the + * settler's own verdict; a row still running, and work outside any turn, take the settler's `end`. + * Every settler that ends running calls asks this, so a call never disagrees with its turn. */ +export function runningCallEnd( + turnScope: AgentJournalTurnScope, + itemBody: (itemId: string) => AgentJournalItemBody | null | undefined, + end: AgentJournalRunningCallEnd +): AgentJournalRunningCallEnd { + const row = + turnScope.kind === 'turn' + ? readAgentJournalTurn(itemBody(turnScope.turnItemId) ?? undefined) + : null + return row && row.state !== 'running' ? row.state : end +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-tool-ends.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-tool-ends.test.ts index 11e18d7a38a8..3c0d548ea38c 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-tool-ends.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-tool-ends.test.ts @@ -122,6 +122,53 @@ describe('provider timeline: how a call its turn or session ended reads', () => expect(await lifecycle(rig)).toBe('failed') }) + it.each([ + [ + 'the session ends unverified', + async (rig: ProviderTimelineRig) => { + rig.assembler.apply({ type: 'session.ended', verdict: { state: 'unverifiable' } }) + } + ], + [ + 'a restarted host settles it with no proof the child died', + async (rig: ProviderTimelineRig) => { + await rig.restart() + } + ] + ])( + 'reads interrupted, as its turn does, when a person stopped its turn and %s', + async (_, settle) => { + const rig = await openTool() + await personStops(rig) + rig.assembler.apply({ type: 'activity', text: 'Thinking' }) + expect(await toolBody(rig)).toMatchObject({ state: 'running' }) + await settle(rig) + expect(await toolBody(rig)).toMatchObject({ state: 'failed', endedAs: 'interrupted' }) + expect(await rig.turn('turn-1')).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation', + completedAt: 2_000 + }) + } + ) + + it('keeps a call that failed before a person stopped its turn failed across a restart', async () => { + const rig = await openTool() + rig.assembler.apply({ type: 'item.open', item: 'call-b', body: runningTool('read') }) + rig.assembler.apply({ + type: 'item.close', + item: 'call-a', + body: { ...runningTool('shell'), state: 'failed' }, + join: { turn: 'turn-1' } + }) + await personStops(rig) + await rig.restart() + const failed = await toolBody(rig, 'call-a') + expect(failed).toMatchObject({ state: 'failed' }) + expect(failed).not.toHaveProperty('endedAs') + expect(await lifecycle(rig, 'call-b')).toBe('interrupted') + }) + it('keeps the provider cancelling a call, and a later turn end does not restate it', async () => { const rig = await openTool() rig.assembler.apply({ diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts index b5f5e231559a..00c767731cee 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -6,14 +6,12 @@ // trusted from memory, so a prompt a client answered a moment earlier stays answered, and a // second settlement finds nothing left to do. -import type { AgentJournalRunningCallEnd } from '../../../shared/agent-journal-tool-call-lifecycle' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemBody, type AgentJournalItemIdentity, - type AgentJournalTurnLifecycle, - type AgentJournalTurnScope + type AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' import { agentJournalTurnBody, @@ -21,7 +19,10 @@ import { } from '../../../shared/agent-session-turn-record' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import { lostLiveWorkJournalBody } from '../agent-session-journal/journal-subagent-liveness' -import { terminalAgentJournalBody } from '../agent-session-journal/journal-terminal-settlement' +import { + runningCallEnd, + terminalAgentJournalBody +} from '../agent-session-journal/journal-terminal-settlement' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { agentJournalTurnRowReservedBytes, @@ -87,7 +88,7 @@ export function providerTimelineSettlement( (turnScope.kind === 'turn' && turnScope.turnItemId === scope.turnItemId) const end = covered && ending && body.kind === 'tool-call' && body.state === 'running' - ? runningCallEnd(journal, turnScope, ending.end) + ? runningCallEnd(turnScope, (turnItemId) => journal.itemBody(turnItemId), ending.end.state) : null // Background tasks and subagents outlive turns; only the session's end leaves them past seeing. const settled = !covered @@ -109,21 +110,6 @@ export function providerTimelineSettlement( return mutations } -/** A call still running when its turn ends ends as that turn's row ends. A row another writer - * settled first (a person's Stop) stands, so its calls take that row's state, not the provider's - * later report; a row this settlement ends, and work outside any turn, take this settlement's end. */ -function runningCallEnd( - journal: StructuredAgentSessionTransitionJournal, - turnScope: AgentJournalTurnScope, - end: ProviderTimelineTurnEnd -): AgentJournalRunningCallEnd { - const row = - turnScope.kind === 'turn' - ? readAgentJournalTurn(journal.itemBody(turnScope.turnItemId) ?? undefined) - : null - return row && row.state !== 'running' ? row.state : end.state -} - /** Every turn row the journal holds running, for the session's end. */ export function runningProviderTimelineTurns( journal: StructuredAgentSessionTransitionJournal 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 176df9ca842e..3f5a0cc9678f 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 @@ -15,6 +15,7 @@ import { partitionJournalLifecycleMutations } from '../agent-session-journal/jou import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import { requiresTerminalSettlement, + runningCallEnd, terminalAgentJournalBody } from '../agent-session-journal/journal-terminal-settlement' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -177,10 +178,18 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { turnScope: exitedRootTurnScope(items, input.verdict) }) } + const bodies = new Map(items.map((item) => [item.itemId, item.body])) for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) // Ended as its turn is: a proven death cuts a running call short. - const body = terminalAgentJournalBody(item.body, input.verdict.state) + const body = terminalAgentJournalBody( + item.body, + runningCallEnd( + item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE, + (turnItemId) => bodies.get(turnItemId), + input.verdict.state + ) + ) if (identity && body) { mutations.push({ kind: 'item', @@ -240,7 +249,16 @@ export async function settleStaleStructuredAgentSessionState(input: { const mutations = provenUnverifiedToolCallRevisions(items, input.deathEvidence, journal) for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) - const body = terminalAgentJournalBody(item.body, verdictFor(item).state) + // A turn already settled (a person's Stop) ends its calls as it ended; only a turn still running + // leaves them to the evidence. + const body = terminalAgentJournalBody( + item.body, + runningCallEnd( + item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE, + (turnItemId) => journal.itemBody(turnItemId), + verdictFor(item).state + ) + ) if (identity && body) { mutations.push({ kind: 'item', From 70fd4bac5bd829dae87aa723e7da735cb2e44e56 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:24:58 -0700 Subject: [PATCH 143/269] Keep the dead-generation settlement under the line cap --- .../journal-terminal-settlement.ts | 4 ++-- ...gent-session-dead-generation-settlement.ts | 20 ++++--------------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts b/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts index d5787fb6394b..073dcd12bc7d 100644 --- a/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts +++ b/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts @@ -46,12 +46,12 @@ export function terminalAgentJournalBody( * settler's own verdict; a row still running, and work outside any turn, take the settler's `end`. * Every settler that ends running calls asks this, so a call never disagrees with its turn. */ export function runningCallEnd( - turnScope: AgentJournalTurnScope, + turnScope: AgentJournalTurnScope | undefined, itemBody: (itemId: string) => AgentJournalItemBody | null | undefined, end: AgentJournalRunningCallEnd ): AgentJournalRunningCallEnd { const row = - turnScope.kind === 'turn' + turnScope?.kind === 'turn' ? readAgentJournalTurn(itemBody(turnScope.turnItemId) ?? undefined) : null return row && row.state !== 'running' ? row.state : end 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 3f5a0cc9678f..bd6c9dcfbf76 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 @@ -182,14 +182,8 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) // Ended as its turn is: a proven death cuts a running call short. - const body = terminalAgentJournalBody( - item.body, - runningCallEnd( - item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE, - (turnItemId) => bodies.get(turnItemId), - input.verdict.state - ) - ) + const end = runningCallEnd(item.turnScope, (id) => bodies.get(id), input.verdict.state) + const body = terminalAgentJournalBody(item.body, end) if (identity && body) { mutations.push({ kind: 'item', @@ -251,14 +245,8 @@ export async function settleStaleStructuredAgentSessionState(input: { const identity = parseAgentJournalItemKey(item.itemId) // A turn already settled (a person's Stop) ends its calls as it ended; only a turn still running // leaves them to the evidence. - const body = terminalAgentJournalBody( - item.body, - runningCallEnd( - item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE, - (turnItemId) => journal.itemBody(turnItemId), - verdictFor(item).state - ) - ) + const end = runningCallEnd(item.turnScope, (id) => journal.itemBody(id), verdictFor(item).state) + const body = terminalAgentJournalBody(item.body, end) if (identity && body) { mutations.push({ kind: 'item', From 7aa40a99e5e0d0e2a03508767b22bd6071c817c3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:26:45 -0700 Subject: [PATCH 144/269] Register the ACP schema verify step in the PR preflight phase test --- config/scripts/pr-preflight-gates.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index 4be1aa2b740c..7ba8a881d8f3 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -200,6 +200,7 @@ it('pins every foreground and background step to its selected phase', () => { ['Check Node runtime pin', staticPhase], ['Boot orcad and round-trip a terminal', staticPhase], ['Verify the generated RPC params catalog', staticPhase], + ['Verify the generated ACP protocol schema', staticPhase], ['Verify bundled skill guides', staticPhase], ['Verify skill freshness manifest', staticPhase], ['Verify localization coverage', staticPhase], From 19c8cb5f417486d92147dbd7c8db99001a6eeff7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:50:46 -0700 Subject: [PATCH 145/269] refactor(agent-session): one required agent registry; declarations admit what they claim A StructuredAgentRegistry, built once from the {definition, adapter} registrations, is now a required host dependency and the router routes with it. The adapter interface loses its optional router-only capabilities?() and definition?(); every reader (options at rest, thread goal, rewind, the /compact handover) asks the registry. A live session still narrows rewind through the adapter, and the host combines declared and narrowed in one helper. The registry refuses a registration that declares compact, a thread goal or rewind without the adapter method behind it. /compact is admitted by the declared capability, so an agent that declares compact:false gets the commandRefused fact instead of a thrown error. Also: the cut-turn notice names the agent from the catalog, create-support builds the account home through agentSessionAccountHome, and the turn status text older clients read names the session's agent instead of defaulting to Codex (Claude/Codex text unchanged). --- .../claude-api-retry-idle-sweep.test.ts | 2 + ...claude-context-usage-unloaded-turn.test.ts | 29 +++--- ...-structured-effort-default-at-rest.test.ts | 11 ++- .../claude-structured-session-test-support.ts | 10 +++ .../codex-provider-retry-idle-sweep.test.ts | 2 + .../codex-structured-question-order.test.ts | 2 + .../structured-agent-registry.ts | 86 ++++++++++++++++++ ...-agent-session-accept-then-deliver.test.ts | 2 + ...nt-session-adapter-router-registry.test.ts | 90 ++++++++++++------- ...ent-session-adapter-router-test-support.ts | 62 ++++++++++--- ...structured-agent-session-adapter-router.ts | 49 ++++------ .../structured-agent-session-adapter.ts | 11 +-- ...tured-agent-session-adopted-import.test.ts | 2 + ...ured-agent-session-append-delivery.test.ts | 2 + ...red-agent-session-at-rest-commands.test.ts | 2 + ...-agent-session-claude-compact-stop.test.ts | 2 + ...-agent-session-claude-echo-working.test.ts | 2 + ...-agent-session-claude-option-queue.test.ts | 2 + ...d-agent-session-claude-queued-stop.test.ts | 2 + ...red-agent-session-claude-root-exit.test.ts | 2 + ...t-session-claude-stop-ends-session.test.ts | 12 +-- ...agent-session-claude-stop-turn-end.test.ts | 2 + ...-session-claude-unproven-stop-send.test.ts | 8 +- ...ctured-agent-session-close-verdict.test.ts | 2 + ...tured-agent-session-codex-stop-row.test.ts | 2 + .../structured-agent-session-command-turn.ts | 8 +- ...ed-agent-session-conversation-stop.test.ts | 2 + ...ured-agent-session-crash-mid-start.test.ts | 2 + ...tured-agent-session-crash-turn-end.test.ts | 2 + .../structured-agent-session-delivery-loop.ts | 3 + ...ession-failed-create-owner-verdict.test.ts | 2 + ...session-failed-create-sink-release.test.ts | 2 + ...tured-agent-session-grouped-prompt.test.ts | 2 + .../structured-agent-session-host-delivery.ts | 1 + ...ed-agent-session-host-test-abandon.test.ts | 2 + ...uctured-agent-session-host-test-harness.ts | 2 + .../structured-agent-session-host-types.ts | 3 + .../structured-agent-session-host.test.ts | 3 + ...ured-agent-session-import-mismatch.test.ts | 2 + ...ured-agent-session-late-settlement.test.ts | 2 + ...gent-session-legacy-handoff-record.test.ts | 2 + ...ssion-main-agent-working-agreement.test.ts | 2 + ...ctured-agent-session-mutation-admission.ts | 36 ++------ ...ructured-agent-session-mutation-context.ts | 1 + ...red-agent-session-mutation-turn-context.ts | 38 ++++++++ ...agent-session-operation-settlement.test.ts | 3 + ...nt-session-operation-start-refusal.test.ts | 4 +- ...ed-agent-session-option-settlement.test.ts | 2 + ...uctured-agent-session-options-read.test.ts | 8 +- .../structured-agent-session-options-read.ts | 26 +++--- ...ed-agent-session-owed-work-release.test.ts | 2 + ...ctured-agent-session-prompt-cancel.test.ts | 3 + ...gent-session-provider-child-record.test.ts | 2 + ...red-agent-session-provider-restore.test.ts | 2 + ...red-agent-session-provider-started.test.ts | 2 + ...session-queued-message-rig.test-fixture.ts | 8 +- ...tured-agent-session-recovery-exits.test.ts | 2 + ...ctured-agent-session-refusal-retry.test.ts | 2 + ...gent-session-registered-definition.test.ts | 68 +++++++------- ...uctured-agent-session-repeat-press.test.ts | 47 +++++----- ...ctured-agent-session-repeated-stop.test.ts | 2 + .../structured-agent-session-rest-test-rig.ts | 6 +- ...ssion-restart-interruption-test-harness.ts | 2 + ...session-restart-status-publication.test.ts | 2 + ...tured-agent-session-rewind-at-rest.test.ts | 2 + .../structured-agent-session-rewind.test.ts | 2 + .../structured-agent-session-rewind.ts | 13 ++- ...red-agent-session-send-idempotency.test.ts | 5 ++ ...agent-session-send-open-stale-turn.test.ts | 2 + ...red-agent-session-send-preparation.test.ts | 2 + ...session-send-restarts-failed-start.test.ts | 2 + ...agent-session-settled-attach-retry.test.ts | 7 ++ ...agent-session-start-failure-writer.test.ts | 2 + ...-session-startup-reconcile-failure.test.ts | 2 + ...red-agent-session-surface-lifetime.test.ts | 2 + ...ructured-agent-session-thread-goal.test.ts | 45 ++++------ .../structured-agent-session-thread-goal.ts | 2 +- .../structured-agent-session-turns.test.ts | 15 ++++ .../structured-agent-session-turns.ts | 5 ++ ...sion-view-start-after-failed-start.test.ts | 2 + ...t-session-wedged-profile-migration.test.ts | 2 + .../structured-conversation-command.test.ts | 4 +- .../structured-conversation-command.ts | 1 + ...structured-conversation-compaction.test.ts | 33 ++++++- ...structured-agent-session-create-support.ts | 8 +- ...ured-agent-session-adoption-replay.test.ts | 2 + .../structured-agent-session-at-rest.test.ts | 9 +- .../structured-agent-session-hold.test.ts | 2 + .../methods/structured-chat-tab-table.test.ts | 2 + .../structured-agent-session-runtime.ts | 18 ++-- .../structured-claude-pending-rewind.test.ts | 9 +- ...red-agent-session.cut-turn-notice.test.tsx | 15 ++++ .../use-structured-agent-session.ts | 3 +- src/shared/agent-session-turn-record.test.ts | 16 ++++ src/shared/agent-session-turn-record.ts | 9 +- src/shared/agent-turn-lifecycle-text.ts | 2 +- src/shared/tui-agent-display-names.ts | 5 ++ ...ss-version-agent-session-wire.unit.test.ts | 3 + ...-chat-owner-status-activation.unit.test.ts | 2 + 99 files changed, 669 insertions(+), 294 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-registry.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index 695acefbb6f8..c5a05cfb5569 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -20,6 +20,7 @@ import { createClaudeJournalTranslator } from './claude-structured-journal-trans import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const SWEEP_MS = 5 const RETRY_GAP_MS = 10 * 60_000 @@ -79,6 +80,7 @@ beforeEach(async () => { setOption: async () => undefined } host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index d98d1e68371e..c1ce921caca2 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -16,7 +16,6 @@ import { import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { readAgentSessionHistory } from '../native-chat/agent-session-wire/agent-session-history-page' -import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read' @@ -29,7 +28,11 @@ import { import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' -import { CLAUDE_STRUCTURED_AGENT } from './claude-structured-agent-definition' +import { + claudeAndCodexAgents, + NO_STRUCTURED_AGENTS +} from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import type { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' const SESSION = 'orca-session' const journals = createTrackedJournalOpener() @@ -82,10 +85,7 @@ function runTurn( }) } -function readOptions( - journal: AgentSessionJournal, - adapter: Partial -) { +function readOptions(journal: AgentSessionJournal, agents: StructuredAgentRegistry) { const running = { journal, child: { fence: 1, generation: 'generation-1' }, @@ -95,9 +95,9 @@ function readOptions( const context = { deps: { adapter: { - readOptions: async () => ({ models: [], current: { model: 'opus' } }), - ...adapter + readOptions: async () => ({ models: [], current: { model: 'opus' } }) }, + agents, store: { getRecord: () => undefined } }, serialize: (_sessionId: string, task: () => Promise) => task(), @@ -172,9 +172,7 @@ describe('context usage for a turn row outside the loaded page', () => { const state = attachTail(journal, 3) expect(state.hasOlder).toBe(true) expect(hasTurnRow(state)).toBe(false) - const options = await readOptions(journal, { - capabilities: () => CLAUDE_STRUCTURED_AGENT.capabilities - }) + const options = await readOptions(journal, claudeAndCodexAgents()) expect(options.contextUsage?.current).toEqual( latestStructuredAgentContextFacts(journal.snapshot().items) @@ -193,7 +191,7 @@ describe('context usage for a turn row outside the loaded page', () => { runTurn(translator, 'turn-a', 1_000, [120_000, 130_000, 140_000]) await settle() - const options = await readOptions(journal, {}) + const options = await readOptions(journal, NO_STRUCTURED_AGENTS) expect(options).not.toHaveProperty('contextUsage') // An older host answers the same way, so the ring reads only the loaded page, as before. expect(selectStructuredAgentContextUsage(attachTail(journal, 2).items)).toBeNull() @@ -216,9 +214,7 @@ describe('context usage for a turn row outside the loaded page', () => { expect(hasTurnRow(live)).toBe(false) expect(live.unloadedTurnRevisions).toBe(1) - const options = await readOptions(journal, { - capabilities: () => CLAUDE_STRUCTURED_AGENT.capabilities - }) + const options = await readOptions(journal, claudeAndCodexAgents()) expect( selectStructuredAgentContextUsage(live.items, options.contextUsage?.current) ).toMatchObject({ usedTokens: 250_000, windowTokens: 1_000_000 }) @@ -232,8 +228,7 @@ describe('context usage for a turn row outside the loaded page', () => { runTurn(translator, 'turn-b', 3_000, []) await settle() const hostAnswer = async () => - (await readOptions(journal, { capabilities: () => CLAUDE_STRUCTURED_AGENT.capabilities })) - .contextUsage?.current + (await readOptions(journal, claudeAndCodexAgents())).contextUsage?.current // The client reads the host once per turn, and again whenever its window loses a turn row. let state = attachTail(journal, 200) let host = await hostAnswer() diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 61abbfbba30b..edb7c2d0919a 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -14,7 +14,7 @@ import { agentModelCatalogFingerprintForRecord } from '../native-chat/agent-mode import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read' -import { claudeAndCodexDefinition } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import { composeCodexSessionOptionCatalog } from '../codex/codex-structured-model-catalog' import { nativeSessionOptionsFromReport } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' import { @@ -146,11 +146,16 @@ function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { getRecord: () => record, resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }) }) - const resting = { child: null, params: { provider: 'claude' } } + const resting = { + child: null, + params: { provider: 'claude' }, + journal: { threadGoal: () => null, contextUsage: () => null } + } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. const context = { deps: { - adapter: { definition: claudeAndCodexDefinition }, + adapter: {}, + agents: claudeAndCodexAgents(), store: { getRecord: () => record }, modelCatalog }, diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 02cf46aadc83..2b5d28c99bcb 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -325,3 +325,13 @@ export function recordingJournalSink(): StructuredAgentSessionEventSink { export function tick(): Promise { return new Promise((resolve) => setImmediate(resolve)) } + +/** Delivers one frame from Claude on `connection`, under the provider session it runs. */ +export function claudeFrame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +/** Whether anything sent to Claude on `connection` carries `text`. */ +export function claudeWasSent(connection: FakeConnection, text: string): boolean { + return connection.sent.some((message) => JSON.stringify(message).includes(text)) +} diff --git a/src/main/codex/codex-provider-retry-idle-sweep.test.ts b/src/main/codex/codex-provider-retry-idle-sweep.test.ts index 722858828f25..d0263b0c100e 100644 --- a/src/main/codex/codex-provider-retry-idle-sweep.test.ts +++ b/src/main/codex/codex-provider-retry-idle-sweep.test.ts @@ -20,6 +20,7 @@ import { createCodexJournalTranslator } from './codex-structured-journal-transla import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const SWEEP_MS = 5 const RETRY_GAP_MS = 10 * 60_000 @@ -77,6 +78,7 @@ beforeEach(async () => { setOption: async () => undefined } host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index c63d78439aba..f942b5f83a88 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -38,6 +38,7 @@ import { import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -95,6 +96,7 @@ beforeEach(async () => { } store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-registry.ts b/src/main/native-chat/agent-session-wire/structured-agent-registry.ts new file mode 100644 index 000000000000..21e29c008ab7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-registry.ts @@ -0,0 +1,86 @@ +// The structured agents this runtime drives, built once from their registrations. +// +// The one place a definition is looked up: the router routes with it and every host reader asks it +// what an agent declares. A declaration the agent's adapter could not honour is refused here, so a +// declared capability always has the adapter method behind it. + +import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' +import type { AgentSessionRewindSupport } from '../../../shared/agent-session-rewind' +import type { StructuredAgentDefinition } from './structured-agent-definition' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +/** One structured agent this runtime drives: its definition, and the adapter that runs it. */ +export type StructuredAgentRegistration = { + definition: StructuredAgentDefinition + adapter: StructuredAgentSessionAdapter +} + +type AdapterMethod = keyof StructuredAgentSessionAdapter + +/** The adapter methods each declared capability needs. */ +function requiredAdapterMethods(capabilities: AgentSessionCapabilities): AdapterMethod[] { + return [ + ...(capabilities.compact ? (['compact'] as const) : []), + ...(capabilities.threadGoal ? (['changeThreadGoal'] as const) : []), + ...(capabilities.rewind ? (['rewind', 'recoverRewind'] as const) : []) + ] +} + +export class StructuredAgentRegistry { + private readonly byAgent: ReadonlyMap + + constructor(registrations: readonly StructuredAgentRegistration[]) { + const byAgent = new Map() + for (const registration of registrations) { + const { agent, capabilities } = registration.definition + if (byAgent.has(agent)) { + throw new Error(`structured agent ${agent} is registered twice`) + } + const missing = requiredAdapterMethods(capabilities).find( + (method) => !registration.adapter[method] + ) + if (missing) { + throw new Error( + `structured agent ${agent} declares a capability its adapter has no ${missing} for` + ) + } + byAgent.set(agent, registration) + } + this.byAgent = byAgent + } + + /** The registration for `agent`; null for an agent this runtime does not drive. */ + registration(agent: string): StructuredAgentRegistration | null { + return this.byAgent.get(agent) ?? null + } + + registrations(): readonly StructuredAgentRegistration[] { + return [...this.byAgent.values()] + } + + definition(agent: string): StructuredAgentDefinition | null { + return this.byAgent.get(agent)?.definition ?? null + } + + definitions(): readonly StructuredAgentDefinition[] { + return this.registrations().map((registration) => registration.definition) + } + + /** What `agent` declares; a live session may narrow it (see `rewindSupport`), never widen it. */ + capabilities(agent: string): AgentSessionCapabilities | null { + return this.definition(agent)?.capabilities ?? null + } +} + +/** Whether this session can rewind now: its agent's declaration, narrowed by the adapter. */ +export function structuredAgentRewindSupport( + agents: StructuredAgentRegistry, + adapter: Pick, + sessionId: string, + agent: string +): AgentSessionRewindSupport { + if (!agents.capabilities(agent)?.rewind) { + return { supported: false, reason: 'unsupported' } + } + return adapter.rewindSupport?.(sessionId, agent) ?? { supported: true } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 7cc6e05a6a9b..8272dc897746 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -47,6 +47,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -78,6 +79,7 @@ const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spa async function startHost(): Promise { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts index 516296a06d5e..a0564157810b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts @@ -10,6 +10,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import { StructuredAgentRegistry, structuredAgentRewindSupport } from './structured-agent-registry' import type { StructuredAgentDefinition } from './structured-agent-definition' const LOCAL: AgentSessionExecutionLocation = { @@ -71,11 +72,33 @@ const PILOT: StructuredAgentDefinition = { } } +/** A double with every method Claude's and Codex's declarations need. */ +function declaringAdapter( + overrides: Partial = {} +): StructuredAgentSessionAdapter { + return fakeAdapter({ + compact: vi.fn(), + changeThreadGoal: vi.fn(), + rewind: vi.fn(), + recoverRewind: vi.fn(), + ...overrides + }) +} + +function claudeAndCodex( + adapters: { claude?: StructuredAgentSessionAdapter; codex?: StructuredAgentSessionAdapter } = {} +): StructuredAgentRegistry { + return new StructuredAgentRegistry([ + { definition: CLAUDE_STRUCTURED_AGENT, adapter: adapters.claude ?? declaringAdapter() }, + { definition: CODEX_STRUCTURED_AGENT, adapter: adapters.codex ?? declaringAdapter() } + ]) +} + describe('StructuredAgentSessionAdapterRouter registry', () => { it('routes a registered agent the router has no code for, and refuses an unregistered one', async () => { const pilot = fakeAdapter() const router = new StructuredAgentSessionAdapterRouter( - [{ definition: PILOT, adapter: pilot }], + new StructuredAgentRegistry([{ definition: PILOT, adapter: pilot }]), async () => {} ) @@ -91,51 +114,54 @@ describe('StructuredAgentSessionAdapterRouter registry', () => { it('refuses two registrations for one agent', () => { expect( () => - new StructuredAgentSessionAdapterRouter( - [ - { definition: PILOT, adapter: fakeAdapter() }, - { definition: PILOT, adapter: fakeAdapter() } - ], - async () => {} - ) + new StructuredAgentRegistry([ + { definition: PILOT, adapter: fakeAdapter() }, + { definition: PILOT, adapter: fakeAdapter() } + ]) ).toThrow('structured agent grok is registered twice') }) - it('answers capabilities from the live owner, else from the agent named at rest', async () => { - const router = new StructuredAgentSessionAdapterRouter( - [ - { definition: CLAUDE_STRUCTURED_AGENT, adapter: fakeAdapter() }, - { definition: CODEX_STRUCTURED_AGENT, adapter: fakeAdapter() } - ], - async () => {} + it.each([ + ['compact', 'compact'], + ['threadGoal', 'changeThreadGoal'], + ['rewind', 'rewind'], + ['rewind', 'recoverRewind'] + ] as const)('refuses a declared %s whose adapter has no %s', (capability, method) => { + const declared: StructuredAgentDefinition = { + ...PILOT, + capabilities: { ...PILOT.capabilities, [capability]: true } + } + const adapter = declaringAdapter({ [method]: undefined }) + + expect(() => new StructuredAgentRegistry([{ definition: declared, adapter }])).toThrow( + `structured agent grok declares a capability its adapter has no ${method} for` ) + expect(() => new StructuredAgentRegistry([{ definition: PILOT, adapter }])).not.toThrow() + }) - expect(router.capabilities('session-1')).toBeUndefined() - expect(router.capabilities('session-1', 'codex')).toBe(CODEX_STRUCTURED_AGENT.capabilities) - expect(router.capabilities('session-1', 'grok')).toBeUndefined() - await router.acquire({ identity: identity('session-1', 'claude'), fence: 1, spawnToken: 's' }) - // A live session answers for its own agent, whatever a caller names. - expect(router.capabilities('session-1', 'codex')).toBe(CLAUDE_STRUCTURED_AGENT.capabilities) + it('answers what each registered agent declares, and nothing for an unregistered one', () => { + const agents = claudeAndCodex() + + expect(agents.capabilities('codex')).toBe(CODEX_STRUCTURED_AGENT.capabilities) + expect(agents.capabilities('claude')).toBe(CLAUDE_STRUCTURED_AGENT.capabilities) + expect(agents.capabilities('grok')).toBeNull() + expect(agents.definition('grok')).toBeNull() + expect(agents.definitions()).toEqual([CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT]) }) it('lets a session narrow a declared rewind but never widen an undeclared one', () => { - const narrowing = fakeAdapter({ + const narrowing = declaringAdapter({ rewindSupport: () => ({ supported: false, reason: 'history-not-paginated' }) }) - const widening = fakeAdapter({ rewindSupport: () => ({ supported: true }) }) - const router = new StructuredAgentSessionAdapterRouter( - [ - { definition: CODEX_STRUCTURED_AGENT, adapter: narrowing }, - { definition: CLAUDE_STRUCTURED_AGENT, adapter: widening } - ], - async () => {} - ) + const widening = declaringAdapter({ rewindSupport: () => ({ supported: true }) }) + const agents = claudeAndCodex({ codex: narrowing, claude: widening }) + const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) - expect(router.rewindSupport('session-1', 'codex')).toEqual({ + expect(structuredAgentRewindSupport(agents, router, 'session-1', 'codex')).toEqual({ supported: false, reason: 'history-not-paginated' }) - expect(router.rewindSupport('session-1', 'claude')).toEqual({ + expect(structuredAgentRewindSupport(agents, router, 'session-1', 'claude')).toEqual({ supported: false, reason: 'unsupported' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts index ef9e6c9f5a38..4d1dffb8a391 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts @@ -3,22 +3,62 @@ import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-defin import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' import type { StructuredAgentDefinition } from './structured-agent-definition' +import { StructuredAgentRegistry } from './structured-agent-registry' -/** A router with this build's two agents registered under their real definitions. */ +/** A runtime that drives no agent: what a host over a bare adapter double declares. */ +export const NO_STRUCTURED_AGENTS = new StructuredAgentRegistry([]) + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only the optional methods a declaration needs; this double has none, so its agents declare none. +const NO_METHODS = {} as StructuredAgentSessionAdapter + +/** This build's two agents over doubles. Each declares only what its double implements, so a + * double need not carry every method its real adapter has. */ +export function claudeAndCodexAgents( + adapters: + | { claude: StructuredAgentSessionAdapter; codex: StructuredAgentSessionAdapter } + | StructuredAgentSessionAdapter = NO_METHODS +): StructuredAgentRegistry { + const { claude, codex } = 'claude' in adapters ? adapters : { claude: adapters, codex: adapters } + return new StructuredAgentRegistry([ + { definition: implementedBy(CLAUDE_STRUCTURED_AGENT, claude), adapter: claude }, + { definition: implementedBy(CODEX_STRUCTURED_AGENT, codex), adapter: codex } + ]) +} + +/** This build's two agents exactly as declared, for a host whose double gains its methods per test. */ +export function claudeAndCodexDeclared(): StructuredAgentRegistry { + const unused = (): never => { + throw new Error('a registry never calls its adapters') + } + return claudeAndCodexAgents({ + ...NO_METHODS, + compact: unused, + changeThreadGoal: unused, + rewind: unused, + recoverRewind: unused + }) +} + +/** A router over this build's two agents. */ export function claudeAndCodexRouter( adapters: { claude: StructuredAgentSessionAdapter; codex: StructuredAgentSessionAdapter }, closeAdapters: () => Promise ): StructuredAgentSessionAdapterRouter { - return new StructuredAgentSessionAdapterRouter( - [ - { definition: CLAUDE_STRUCTURED_AGENT, adapter: adapters.claude }, - { definition: CODEX_STRUCTURED_AGENT, adapter: adapters.codex } - ], - closeAdapters - ) + return new StructuredAgentSessionAdapterRouter(claudeAndCodexAgents(adapters), closeAdapters) } -/** The router's definition lookup over the same two agents, for a double that stands in for it. */ -export function claudeAndCodexDefinition(agent: string): StructuredAgentDefinition | null { - return [CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT].find((d) => d.agent === agent) ?? null +function implementedBy( + definition: StructuredAgentDefinition, + adapter: StructuredAgentSessionAdapter +): StructuredAgentDefinition { + const declared = definition.capabilities + return { + ...definition, + capabilities: { + ...declared, + compact: declared.compact && Boolean(adapter.compact), + threadGoal: declared.threadGoal && Boolean(adapter.changeThreadGoal), + rewind: declared.rewind && Boolean(adapter.rewind && adapter.recoverRewind) + } + } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 491702059c7c..b7191d50f54c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -6,44 +6,30 @@ import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import type { StructuredAgentDefinition } from './structured-agent-definition' -import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' - -/** One structured agent this runtime drives: its definition, and the adapter that runs it. */ -export type StructuredAgentRegistration = { - definition: StructuredAgentDefinition - adapter: StructuredAgentSessionAdapter -} +import type { + StructuredAgentRegistration, + StructuredAgentRegistry +} from './structured-agent-registry' type SessionRoute = { registration: StructuredAgentRegistration state: 'live' | 'stopped' } -/** Routes each session to the adapter registered for its agent. Adding an agent is one more +/** Routes each session to the adapter its agent is registered with. Adding an agent is one more * registration; nothing here names one. */ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessionAdapter { private readonly routes = new Map() - private readonly registrations: ReadonlyMap private allAdaptersClosed = false private closePromise: Promise | null = null constructor( - registrations: readonly StructuredAgentRegistration[], + private readonly agents: StructuredAgentRegistry, private readonly closeAdapters: () => Promise - ) { - const byAgent = new Map() - for (const registration of registrations) { - if (byAgent.has(registration.definition.agent)) { - throw new Error(`structured agent ${registration.definition.agent} is registered twice`) - } - byAgent.set(registration.definition.agent, registration) - } - this.registrations = byAgent - } + ) {} supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => { - const adapter = this.registrations.get(agent)?.adapter + const adapter = this.agents.registration(agent)?.adapter return adapter ? (adapter.supportsLocation?.(location) ?? false) : false } @@ -85,13 +71,13 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) - /** The declared capability, narrowed by the adapter's answer for this session. */ + /** The owning adapter's narrowing for this session; the host combines it with the declaration. */ rewindSupport: NonNullable = ( sessionId, agent ) => { const owner = this.capabilityOwner(sessionId, agent) - if (!owner?.definition.capabilities.rewind) { + if (!owner) { return { supported: false, reason: 'unsupported' } } return owner.adapter.rewindSupport?.(sessionId) ?? { supported: true } @@ -124,12 +110,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return change(input) } - capabilities = (sessionId: string, agent?: string): AgentSessionCapabilities | undefined => - this.capabilityOwner(sessionId, agent)?.definition.capabilities - - definition = (agent: string): StructuredAgentDefinition | null => - this.registrations.get(agent)?.definition ?? null - stopBackgroundTasks: NonNullable = ( input ) => { @@ -160,7 +140,8 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi this.liveOwnerOrNull(sessionId)?.readCommands?.(sessionId) atRestCommands: StructuredAgentSessionAtRestCommands = { - read: (record) => this.registrations.get(record.provider)?.adapter.atRestCommands?.read(record), + read: (record) => + this.agents.registration(record.provider)?.adapter.atRestCommands?.read(record), onChange: (listener) => { const stops = this.adapters().flatMap((adapter) => adapter.atRestCommands ? [adapter.atRestCommands.onChange(listener)] : [] @@ -280,7 +261,7 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi if (route?.state === 'live') { return route.registration } - return agent ? (this.registrations.get(agent) ?? null) : null + return agent ? this.agents.registration(agent) : null } private liveOwnerOrNull(sessionId: string): StructuredAgentSessionAdapter | null { @@ -289,7 +270,7 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi } private requireAgent(identity: AgentSessionJournalIdentity): StructuredAgentRegistration { - const registration = this.registrations.get(identity.agent) + const registration = this.agents.registration(identity.agent) if (!registration) { throw new Error(`structured sessions do not support ${identity.agent}`) } @@ -297,6 +278,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi } private adapters(): StructuredAgentSessionAdapter[] { - return [...this.registrations.values()].map((registration) => registration.adapter) + return this.agents.registrations().map((registration) => registration.adapter) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 9afd50481d2e..5bd42f2b2eb2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -47,8 +47,6 @@ import type { AgentSessionPromptResponse } from '../../../shared/agent-session-q import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' -import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' -import type { StructuredAgentDefinition } from './structured-agent-definition' export class AgentSessionAcquisitionRefusal extends Error { readonly code = 'agent_session_operation_invalid' @@ -267,7 +265,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** Revalidate after preparation, immediately before writing to the provider. */ beforeDispatch?: () => Promise }): Promise - /** `agent` answers for a session with no child running, from the provider alone. */ + /** How this session narrows its agent's declared rewind; `agent` answers for one with no child + * running. The host reads the declaration first: this never widens it. */ rewindSupport?(sessionId: string, agent?: string): AgentSessionRewindSupport recoverRewind?(input: { sessionId: string @@ -321,12 +320,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & * start a new goal rather than rewrite that one's objective in place. */ replacesGoal: boolean }): Promise<{ ok: true } | { ok: false; rejected: string }> - /** What this session's agent declares it can do; `agent` answers one at rest. Answered by the - * router from the agent's registered definition, never by a single adapter. */ - capabilities?(sessionId: string, agent?: string): AgentSessionCapabilities | undefined - /** The definition registered for `agent`, null for an agent this runtime does not drive. Answered - * by the router from its registrations, never by a single adapter. */ - definition?(agent: string): StructuredAgentDefinition | null /** Stops exactly the tasks `taskIds` names, which the host resolves from its child records. */ stopBackgroundTasks?(input: { sessionId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 4f532865452a..621e19d6f949 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -21,6 +21,7 @@ import { StructuredAgentSessionHost } from './structured-agent-session-host' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const NOW = 1_800_000_000_000 const SESSION = 'codex_adopting_session' @@ -236,6 +237,7 @@ describe('adopting a provider conversation on create', () => { await writeCodexRollout(transcriptPath, 'valid source') store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index 5c718da0f3be..b80135108056 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -28,6 +28,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -130,6 +131,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts index 4b52c9446dec..9106bafa107a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts @@ -29,6 +29,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const caller = { callerKey: 'desktop' } const CLAUDE_SESSION = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' @@ -85,6 +86,7 @@ function catalogFor(workspacePath: string): ClaudeAtRestCommandCatalog { async function openHost(catalog = catalogFor(workspace)): Promise { store = await openTestAgentSessionRecordStore(directory) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, store, adapter: adapter(catalog), journalDatabase: openTestJournalHostDatabase(directory), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts index bbdc2b50b2fd..cfb7dfd54834 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts @@ -29,6 +29,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -71,6 +72,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, // The production router is what declares create support; the bare adapter only knows locations. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts index ac1111ac629e..de44f4f8a2ba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts @@ -28,6 +28,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -67,6 +68,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts index 868247eb37ce..88e495b5dbdf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts @@ -18,6 +18,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-claude' } const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' @@ -85,6 +86,7 @@ beforeEach(async () => { optionWritable = Promise.resolve() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index 5e4ac75b0662..744eba82c505 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -30,6 +30,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } // As Claude Code 2.1.280 advertises them on a turn's system/init frame. @@ -83,6 +84,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index f09fe672b5ec..1e6ebdb5dab3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -18,6 +18,7 @@ import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agen import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const NOW = 1_788_727_031_330 const roots: string[] = [] @@ -120,6 +121,7 @@ describe('Claude root-exit stop', () => { const deps = { store, adapter, + agents: NO_STRUCTURED_AGENTS, // The one database the store and the journal share, as the runtime installs them. journalDatabase: openTestJournalHostDatabase(stateDirectory), claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index 198c2418cf93..449b2645d601 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -22,6 +22,8 @@ import { CLAUDE_STOP_GRACE_MS } from '../../claude/claude-request-end-wait' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import type { ClaudeStructuredSessionEvent } from '../../claude/claude-structured-session-state' import { + claudeFrame as frame, + claudeWasSent as wrote, fakeClaude, PROVIDER_SESSION_ID, type FakeConnection @@ -42,6 +44,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } // As Claude Code 2.1.280 advertises them on a turn's system/init frame. @@ -104,6 +107,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, store, adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), @@ -181,10 +185,6 @@ async function dispatch(clientMessageId: string) { return { state: submission?.dispatchState, reason: submission?.reason } } -function frame(connection: FakeConnection, message: Record): void { - connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) -} - /** Sends a message and lets Claude open its turn and write one reply; returns the turn's id. */ async function openTurn(connection: FakeConnection, text = 'Write a long reply.'): Promise { const clientMessageId = await send(text) @@ -246,10 +246,6 @@ function stopEventsAtClose(connection: FakeConnection): () => number | undefined return () => atClose } -function wrote(connection: FakeConnection, text: string): boolean { - return connection.sent.some((message) => JSON.stringify(message).includes(text)) -} - const INTERRUPTED_RESULT = { type: 'result', subtype: 'error_during_execution', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts index 9e10a753f07e..6107c402b889 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts @@ -30,6 +30,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } // As Claude Code 2.1.280 advertises them on a turn's system/init frame. @@ -70,6 +71,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, store, adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index 3c2b77e41b1c..a48bb84e0e13 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -21,7 +21,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { claudeAndCodexDefinition } from './structured-agent-session-adapter-router-test-support' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { HOST_TEST_NOW as NOW, @@ -75,11 +75,9 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: claudeAndCodexAgents(adapter), store, - adapter: Object.assign(adapter, { - supportsCreate: () => true, - definition: claudeAndCodexDefinition - }), + adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index b1922b913ed1..740725917bda 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -29,6 +29,7 @@ import { HOST_TEST_THREAD as THREAD } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CUT_TURN = { provider: 'codex' as const, threadId: THREAD, turnId: 'cut-turn', ordinal: 1 } @@ -49,6 +50,7 @@ beforeEach(() => { exitObservedFirst = false closeCalls = 0 host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store: state.store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 7f828a484688..056d3421ecd0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -28,6 +28,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -70,6 +71,7 @@ beforeEach(async () => { }) disposeSession = vi.spyOn(adapter, 'disposeSession') host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index b1654ff70790..e51b8e672f96 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -42,6 +42,7 @@ import type { StructuredAgentSessionAdapter, StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' +import type { StructuredAgentRegistry } from './structured-agent-registry' import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { conversationCommandBlocked } from './structured-conversation-command-admission' @@ -153,6 +154,7 @@ export type StructuredAgentSessionCommandHandoverContext = { journal: AgentSessionJournal fence: number adapter: StructuredAgentSessionAdapter + agents: StructuredAgentRegistry providerChildPhase?: () => StructuredAgentSessionProviderChildPhase | undefined /** Who a failure the handover meets names, as the start's own row does. */ failureTextContext?: AgentSessionFailureWordsContext @@ -310,13 +312,17 @@ function commandBlocked( ctx: StructuredAgentSessionCommandHandoverContext, body: AgentJournalMessageItem ): SubmissionRejectionFact | null { - if (body.command?.name !== STRUCTURED_AGENT_SESSION_COMPACT_COMMAND || !ctx.adapter.compact) { + if (body.command?.name !== STRUCTURED_AGENT_SESSION_COMPACT_COMMAND) { return agentSessionFailureFact('commandRefused') } const record = ctx.record() if (!record) { return agentSessionFailureFact('hostFault') } + // The declaration admits it, as it does the advertised command list; a client may send it anyway. + if (!ctx.agents.capabilities(record.provider)?.compact) { + return agentSessionFailureFact('commandRefused') + } const refusal = conversationCommandBlocked(ctx, record, ctx.childWork(), 'handover') return refusal ? agentSessionFailureFact('commandRefused', { refusal: agentSessionRefusalReference(refusal) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index 7421fcef307c..bf699a417cdc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -28,6 +28,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -63,6 +64,7 @@ beforeEach(async () => { log = recordingStructuredAgentSessionLogger() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: log.logger, store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts index 059567386a6d..e10d215dc80a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts @@ -26,6 +26,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const CHILD_PID = 4321 @@ -93,6 +94,7 @@ function host( overrides: Partial = {} ): StructuredAgentSessionHost { return new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, 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 a8e0f4be94f9..ac3e264ee0cb 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 @@ -49,6 +49,7 @@ import { import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const PROVIDER_SESSION = 'provider-session-alpha-1' /** The tool call's row: the last thing the provider wrote before the crash. */ @@ -159,6 +160,7 @@ async function seedClaudeToolTurn(): Promise { function openHost(overrides: Partial): void { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 83ab0e8e6feb..50819eaab629 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -22,6 +22,7 @@ import { type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentRegistry } from './structured-agent-registry' import { structuredAgentSessionStartFailure, type StructuredAgentSessionStartFailureCause @@ -52,6 +53,7 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap adapter: StructuredAgentSessionAdapter + agents: StructuredAgentRegistry serialize: (sessionId: string, task: () => Promise) => Promise /** A start step, tracked from enqueue so quit waits for the child it may produce. */ trackStart: (start: Promise) => Promise @@ -261,6 +263,7 @@ export class StructuredAgentSessionDeliveryLoop { journal: session.journal, fence: awaitedChild.fence, adapter: this.deps.adapter, + agents: this.deps.agents, providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase, failureTextContext: this.deps.failureTextContext(sessionId), record: () => this.deps.record(sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index 31e7bb718591..a469af94120e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -25,6 +25,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' @@ -53,6 +54,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts index d6590d6c61a7..e510916b0973 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts @@ -23,6 +23,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): claude: not signed in' @@ -49,6 +50,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index a4a12bfce7ec..f308b7903983 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -26,6 +26,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -135,6 +136,7 @@ beforeEach(async () => { answerPrompt = vi.fn(async ({ commit }) => commit()) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 696236805ac8..aa42efab4c9e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -61,6 +61,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { const loop = new StructuredAgentSessionDeliveryLoop({ sessions, adapter: deps.adapter, + agents: deps.agents, serialize: input.serialize, trackStart: input.trackStart, ensureProviderChild: input.ensureProviderChild, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts index c444f52c031e..f8f9745f8905 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts @@ -21,6 +21,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -68,6 +69,7 @@ describe('abandoning a structured agent-session host', () => { setOption: async () => undefined } const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index 96fc601134b4..ab748f866b5b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -31,6 +31,7 @@ import { } from './structured-agent-session-host-test-data' import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' const journals = createTrackedJournalOpener() @@ -157,6 +158,7 @@ beforeEach(async () => { recoveryCapsule = new TrackedTestRecoveryCapsule(root) log = recordingProductionStructuredAgentSessionLogger() host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), logger: log.logger, store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 1f456038f8d7..b5bc74ab99ad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -18,6 +18,7 @@ import type { AgentSessionAttachParams } from './structured-agent-session-attach import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' import type { AgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { StructuredAgentRegistry } from './structured-agent-registry' import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' export type StructuredAgentSessionCaller = { callerKey: string } @@ -103,6 +104,8 @@ export type StructuredAgentSessionHostSession = { export type StructuredAgentSessionHostDeps = { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter + /** The agents this runtime drives; what each declares is read here, never from the adapter. */ + agents: StructuredAgentRegistry /** Optional advisory recovery storage, independent of conversation backups. */ recoveryCapsule?: AgentSessionRecoveryCapsule /** The host's one chat journal database. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 16251f3cd401..22791df28fa8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -30,6 +30,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' let root: string let store: AgentSessionRecordStore @@ -134,6 +135,7 @@ describe('attach', () => { } })) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), acquire }, @@ -544,6 +546,7 @@ describe('restart', () => { ) { store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), ...adapterOverrides }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts index 5a384ce2bb9e..a8f45664a1d8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts @@ -26,6 +26,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const { copy } = vi.hoisted(() => ({ copy: { mismatched: false } })) @@ -94,6 +95,7 @@ async function relaunchWithMismatchedCopy(): Promise }) const store = await openTestAgentSessionRecordStore(relaunched) const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 81b7200c304e..268b71d152aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -31,6 +31,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -97,6 +98,7 @@ beforeEach(async () => { closeSession = vi.fn(async () => true) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index 00b9f617891d..918afba6c5b3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -26,6 +26,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -44,6 +45,7 @@ let dispatch: Mock function openHost(): void { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts index b8bda4c1b78c..5719dd172cfa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -39,6 +39,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const PROVIDER_ROW = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1' } @@ -64,6 +65,7 @@ beforeEach(async () => { dispatch = vi.fn(async () => ({ state: 'admitted' as const })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 3ed04e1f2fec..3cb9762cf39f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -49,7 +49,9 @@ import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import { mutationTurnContext } from './structured-agent-session-mutation-turn-context' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { StructuredAgentRegistry } from './structured-agent-registry' // The code is shared with the client so a read that refuses this way can be told apart from a // transcript that failed to load; the two must never drift apart. @@ -73,6 +75,7 @@ export type AgentSessionMutationSessionPreparation = export type AgentSessionMutationRequest = { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter + agents: StructuredAgentRegistry logger: StructuredAgentSessionLogger callerKey: string envelope: AgentSessionMutationEnvelope @@ -175,7 +178,7 @@ export async function admitAndRunAgentSessionMutation( } const fence = record.lease.runtimeFence - const context = turnContext(request, journal, fence) + const context = mutationTurnContext(request, journal, record) if (admission.decision === 'replay') { const replayed = replayRecordedOperation(request, context, admission.row) if (replayed !== 'rerun') { @@ -262,7 +265,7 @@ async function answerRecordedOperation( // The row is gone since: the first-run path decides it from scratch. return 'rerun' } - const context = turnContext(request, journal, current.record.lease.runtimeFence) + const context = mutationTurnContext(request, journal, current.record) return replayRecordedOperation(request, context, current.decision.row) } @@ -317,32 +320,3 @@ function admitWithoutLedgerRow( }) return { admission, record: evaluated.record } } - -function turnContext( - request: AgentSessionMutationRequest, - journal: AgentSessionJournal, - fence: number -): AgentSessionTurnContext { - const persistedOptions = request.store.getRecord(request.envelope.sessionId)?.options - return { - sessionId: request.envelope.sessionId, - journal, - fence, - adapter: request.adapter, - logger: request.logger, - ...(persistedOptions ? { persistedOptions } : {}), - persistOptions: (options) => - request.store - .replaceSessionOptions({ - sessionId: request.envelope.sessionId, - fence, - options, - now: request.now() - }) - .then(() => undefined), - resolvedBy: request.callerKey, - publish: () => request.publish(journal), - ...(request.providerChildPhase ? { providerChildPhase: request.providerChildPhase } : {}), - now: () => request.now() - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index e77975dc5c22..35b7d5fb322a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -59,6 +59,7 @@ export function mutateStructuredAgentSession( admitAndRunAgentSessionMutation({ store: context.deps.store, adapter: context.deps.adapter, + agents: context.deps.agents, logger: context.deps.logger, callerKey: caller.callerKey, envelope, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts new file mode 100644 index 000000000000..cae4399cb654 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts @@ -0,0 +1,38 @@ +// The context an admitted mutation's plan runs in, built from the request that admitted it. + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AgentSessionMutationRequest } from './structured-agent-session-mutation-admission' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' + +export function mutationTurnContext( + request: AgentSessionMutationRequest, + journal: AgentSessionJournal, + record: AgentSessionRecord +): AgentSessionTurnContext { + const fence = record.lease.runtimeFence + const persistedOptions = request.store.getRecord(request.envelope.sessionId)?.options + return { + sessionId: request.envelope.sessionId, + journal, + fence, + adapter: request.adapter, + agents: request.agents, + agent: record.provider, + logger: request.logger, + ...(persistedOptions ? { persistedOptions } : {}), + persistOptions: (options) => + request.store + .replaceSessionOptions({ + sessionId: request.envelope.sessionId, + fence, + options, + now: request.now() + }) + .then(() => undefined), + resolvedBy: request.callerKey, + publish: () => request.publish(journal), + ...(request.providerChildPhase ? { providerChildPhase: request.providerChildPhase } : {}), + now: () => request.now() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index a5c44bdb0d0a..2c62c3f945da 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -19,6 +19,7 @@ import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { sendPlan } from './structured-agent-session-mutation-plans' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' async function context(): Promise { return { @@ -35,6 +36,8 @@ async function context(): Promise { stateDirectory: join(hostTestState().root, 'settlement') }), fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: adapter(), persistOptions: async () => {}, resolvedBy: 'test', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts index c3e7909b8cf1..55f3a6fbc72f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts @@ -6,7 +6,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { ensureStructuredAgentSessionAgentForOperation } from './structured-agent-session-agent-start' import { recordStructuredAgentSessionOptionIntent } from './structured-agent-session-options-read' -import { claudeAndCodexDefinition } from './structured-agent-session-adapter-router-test-support' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const SESSION = 'session-1' @@ -50,7 +50,7 @@ describe('an option picked while the chat is at rest', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the intent reads only the record's provider and options. ({ provider: 'codex', options: {} }) as unknown as AgentSessionRecord }, - adapter: { definition: claudeAndCodexDefinition } + agents: claudeAndCodexAgents() }, { sessionId: SESSION, persistOptions, publish: () => {} }, { key: 'notAnOption', value: 'x' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 624828702713..67085dfbcf22 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -23,6 +23,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const DEFAULT_MODEL = 'gpt-default' @@ -126,6 +127,7 @@ beforeEach(async () => { store = await openTestAgentSessionRecordStore(root) router = adapter() host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: router, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index 5657fcfb4d8d..fbb3801d0e0e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -9,6 +9,7 @@ import { } from '../agent-model-catalog/agent-model-catalog-store' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { readStructuredAgentSessionOptions } from './structured-agent-session-options-read' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const SESSION = 'session-1' @@ -35,7 +36,12 @@ describe('options at rest', () => { const resting = { child: null, params: { provider: 'codex' } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. const context = { - deps: { adapter: {}, store: { getRecord: () => record }, modelCatalog }, + deps: { + adapter: {}, + agents: NO_STRUCTURED_AGENTS, + store: { getRecord: () => record }, + modelCatalog + }, serialize: (_sessionId: string, task: () => Promise) => task(), openConversation: async () => resting, conversation: async () => resting diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index e3d75139f6c0..67cc0279294f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -15,7 +15,10 @@ import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structu import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentDefinition } from './structured-agent-definition' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + structuredAgentRewindSupport, + type StructuredAgentRegistry +} from './structured-agent-registry' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { structuredAgentSessionOptionModels } from './structured-agent-session-option-models' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -25,21 +28,21 @@ type RestingOptions = Pick, + agents: Pick, record: AgentSessionRecord ): StructuredAgentDefinition['restingOptions'] | null { - return adapter.definition?.(record.provider)?.restingOptions ?? null + return agents.definition(record.provider)?.restingOptions ?? null } async function readStructuredAgentSessionOptionsAtRest( - deps: Pick, + deps: Pick, sessionId: string ): Promise { const record = deps.store.getRecord(sessionId) if (!record) { throw new Error('agent_session_identity_required') } - const rules = restingOptionRules(deps.adapter, record) + const rules = restingOptionRules(deps.agents, record) const catalog = (await deps.modelCatalog ?.read({ agent: record.provider, sessionId }) .catch(() => null)) ?? { origin: 'unknown' as const } @@ -80,13 +83,13 @@ async function readStructuredAgentSessionOptionsAtRest( export async function recordStructuredAgentSessionOptionIntent( deps: { store: Pick - adapter: Pick + agents: Pick }, ctx: Pick, input: { key: string; value: string } ): Promise> { const record = deps.store.getRecord(ctx.sessionId) - if (!record || !restingOptionRules(deps.adapter, record)?.acceptsKey(input.key)) { + if (!record || !restingOptionRules(deps.agents, record)?.acceptsKey(input.key)) { return { ok: false, refusal: refuse( @@ -110,7 +113,7 @@ export async function readStructuredAgentSessionOptions( >, sessionId: string ): Promise { - const { adapter, store } = context.deps + const { adapter, agents, store } = context.deps const live = await context.serialize(sessionId, async () => { const session = await context.openConversation(sessionId).catch((error: unknown) => { throw journalOpenReadRefusal(error, context.deps.logger, sessionId) @@ -129,16 +132,13 @@ export async function readStructuredAgentSessionOptions( const session = await context.conversation(sessionId) const phase = store.getRecord(sessionId)?.rewind?.phase const agent = session.params.provider - const capabilities = adapter.capabilities?.(sessionId, agent) + const capabilities = agents.capabilities(agent) return { ...options, rewind: phase === 'prepared' || phase === 'provider-succeeded' ? { supported: false, reason: 'outcome-unknown' } - : (adapter.rewindSupport?.(sessionId, agent) ?? { - supported: false, - reason: 'unsupported' - }), + : structuredAgentRewindSupport(agents, adapter, sessionId, agent), conversationCommands: capabilities?.compact ? ['clear', 'compact'] : ['clear'], ...(capabilities?.threadGoal ? { threadGoal: { current: session.journal.threadGoal() } } : {}), ...(capabilities?.contextUsage diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index 8b2586a9ccba..73887ceef69d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -33,6 +33,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const SWEEP_MS = 5 @@ -94,6 +95,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts index 2fa56d3fc6f5..aa1201ed676f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -15,6 +15,7 @@ import { createStructuredAgentSessionLogger } from './structured-agent-session-l import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import type { AgentSessionPromptCancelRoute } from './structured-agent-session-adapter-stop' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -86,6 +87,8 @@ function context( sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { cancelTurn } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'client-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index c61d2bd117f0..6f0c544f377c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -42,6 +42,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { @@ -85,6 +86,7 @@ const spawnStartingChild: StructuredAgentSessionAdapter['acquire'] = async (inpu function startHost(): void { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index 57d249d2dbe4..09421a97cc56 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -18,6 +18,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CLAUDE_SESSION = 'claude-session' const hosts: StructuredAgentSessionHost[] = [] @@ -58,6 +59,7 @@ function createHost( probeOwner?: StructuredAgentSessionHost['deps']['probeOwner'] ): StructuredAgentSessionHost { const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: claudeAdapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index 2922c3fdb2ec..18cfaba38fd0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -24,6 +24,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const INIT_DELAY_MS = 40 @@ -76,6 +77,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, // The production router is what declares create support; the bare adapter only knows locations. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index f1271f4e3395..2122052a3c7e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -28,6 +28,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } @@ -74,6 +75,7 @@ export async function createQueuedMessageTestRig( const store = await openTestAgentSessionRecordStore(root) const makeHost = () => new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter: { @@ -131,11 +133,7 @@ export async function createQueuedMessageTestRig( sessionId, clientOperationId, expectedRuntimeFence: 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId, - fields - }) + payloadFingerprint: computeAgentSessionPayloadFingerprint({ method, sessionId, fields }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index 314e4012199f..772746008e41 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -24,6 +24,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -83,6 +84,7 @@ function adapter(): StructuredAgentSessionAdapter { function openHost(overrides: Partial = {}): void { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index e2b5071d0fa0..bb7732d547f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -31,6 +31,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const METHODS = ['agentSession.setOption', 'agentSession.send'] as const @@ -90,6 +91,7 @@ async function createHarness(options: { attached?: boolean } = {}) { setOption } const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts index 74bacfe930d3..96f1a390155f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts @@ -1,6 +1,6 @@ -// The router's registrations are the only source of an agent's definition: routing, declared -// capabilities and the option rules a chat at rest reads all follow what composition registered, -// and a definition this build ships but did not register decides nothing. +// The registry is the only source of an agent's definition: routing, declared capabilities and the +// option rules a chat at rest reads all follow what composition registered, and a definition this +// build ships but did not register decides nothing. import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -11,6 +11,7 @@ import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-defin import type { StructuredAgentDefinition } from './structured-agent-definition' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import { StructuredAgentRegistry } from './structured-agent-registry' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { readStructuredAgentSessionOptions, @@ -50,25 +51,23 @@ function fakeAdapter(): StructuredAgentSessionAdapter { dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), - setOption: vi.fn() + setOption: vi.fn(), + compact: vi.fn(), + changeThreadGoal: vi.fn() } } -function pick( - adapter: StructuredAgentSessionAdapter, - record: AgentSessionRecord | null, - key: string -) { +function pick(agents: StructuredAgentRegistry, record: AgentSessionRecord | null, key: string) { const persistOptions = vi.fn(async () => {}) const result = recordStructuredAgentSessionOptionIntent( - { store: { getRecord: () => record }, adapter }, + { store: { getRecord: () => record }, agents }, { sessionId: RECORD.sessionId, persistOptions, publish: vi.fn() }, { key, value: 'enabled' } ) return { result, persistOptions } } -function readAtRest(adapter: StructuredAgentSessionAdapter) { +function readAtRest(agents: StructuredAgentRegistry) { const resting = { child: null, params: { provider: RECORD.provider }, @@ -76,7 +75,11 @@ function readAtRest(adapter: StructuredAgentSessionAdapter) { } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. const context = { - deps: { adapter, store: { getRecord: () => RECORD } }, + deps: { + adapter: new StructuredAgentSessionAdapterRouter(agents, async () => {}), + agents, + store: { getRecord: () => RECORD } + }, serialize: (_sessionId: string, task: () => Promise) => task(), openConversation: async () => resting, conversation: async () => resting @@ -87,10 +90,8 @@ function readAtRest(adapter: StructuredAgentSessionAdapter) { describe('a registered definition', () => { it('routes, declares capabilities and rules options at rest for its agent', async () => { const adapter = fakeAdapter() - const router = new StructuredAgentSessionAdapterRouter( - [{ definition: NON_DEFAULT, adapter }], - async () => {} - ) + const agents = new StructuredAgentRegistry([{ definition: NON_DEFAULT, adapter }]) + const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) await router.acquire({ identity: { @@ -104,19 +105,19 @@ describe('a registered definition', () => { spawnToken: 'spawn-1' }) expect(adapter.acquire).toHaveBeenCalledOnce() - expect(router.capabilities(RECORD.sessionId, RECORD.provider)).toBe(NON_DEFAULT.capabilities) + expect(agents.capabilities(RECORD.provider)).toBe(NON_DEFAULT.capabilities) - const accepted = pick(router, RECORD, 'pilotOption') + const accepted = pick(agents, RECORD, 'pilotOption') await expect(accepted.result).resolves.toMatchObject({ ok: true }) expect(accepted.persistOptions).toHaveBeenCalledWith({ model: 'pilot-model', pilotOption: 'enabled' }) // Claude's own module accepts `model`; the registration says otherwise and wins. - const refused = pick(router, RECORD, 'model') + const refused = pick(agents, RECORD, 'model') await expect(refused.result).resolves.toMatchObject({ ok: false }) - const options = await readAtRest(router) + const options = await readAtRest(agents) expect(options.models.map((model) => model.id)).toEqual(['pilot-model']) // No pick for effort: the registered rules read the model's default. expect(options.current).toEqual({ model: 'pilot-model', effort: 'low' }) @@ -124,31 +125,32 @@ describe('a registered definition', () => { }) it('leaves an agent this runtime did not register with no rules, whatever the build ships', async () => { - const router = new StructuredAgentSessionAdapterRouter( - [{ definition: CODEX_STRUCTURED_AGENT, adapter: fakeAdapter() }], - async () => {} - ) + const agents = new StructuredAgentRegistry([ + { + definition: CODEX_STRUCTURED_AGENT, + adapter: { ...fakeAdapter(), rewind: vi.fn(), recoverRewind: vi.fn() } + } + ]) - expect(router.definition(RECORD.provider)).toBeNull() - expect(router.capabilities(RECORD.sessionId, RECORD.provider)).toBeUndefined() - const refused = pick(router, RECORD, 'model') + expect(agents.definition(RECORD.provider)).toBeNull() + expect(agents.capabilities(RECORD.provider)).toBeNull() + const refused = pick(agents, RECORD, 'model') await expect(refused.result).resolves.toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_invalid', details: { reason: 'optionRejected' } } }) expect(refused.persistOptions).not.toHaveBeenCalled() - const options = await readAtRest(router) + const options = await readAtRest(agents) expect(options.models).toEqual([]) expect(options.current).toEqual({ model: 'pilot-model' }) expect(options.conversationCommands).toEqual(['clear']) }) it('refuses a pick for a session with no record, as before', async () => { - const router = new StructuredAgentSessionAdapterRouter( - [{ definition: CLAUDE_STRUCTURED_AGENT, adapter: fakeAdapter() }], - async () => {} - ) - const missing = pick(router, null, 'model') + const agents = new StructuredAgentRegistry([ + { definition: CLAUDE_STRUCTURED_AGENT, adapter: fakeAdapter() } + ]) + const missing = pick(agents, null, 'model') await expect(missing.result).resolves.toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_invalid' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts index 97e7e359f6aa..24546a97bcc0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts @@ -24,7 +24,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -44,31 +44,32 @@ beforeEach(async () => { stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) cancelTurn = vi.fn(async () => ({ cancelled: true })) store = await openTestAgentSessionRecordStore(root) + const adapter: StructuredAgentSessionAdapter = { + acquire: async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: fence > 1 ? ('resumed' as const) : ('created' as const), + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn, + answerPrompt: vi.fn(async () => undefined), + setOption, + changeThreadGoal, + stopBackgroundTasks + } host = new StructuredAgentSessionHost({ + agents: claudeAndCodexAgents(adapter), logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire: async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - acquisitionGeneration: 'generation-1', - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: fence > 1 ? ('resumed' as const) : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - }), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn, - answerPrompt: vi.fn(async () => undefined), - setOption, - changeThreadGoal, - capabilities: () => CODEX_STRUCTURED_AGENT.capabilities, - stopBackgroundTasks - }, + adapter, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts index c0cc13125ca4..7b85104b8a52 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts @@ -23,6 +23,7 @@ import { HOST_TEST_THREAD as THREAD } from './structured-agent-session-host-test-data' import { startAgent } from './structured-agent-session-restart-interruption-test-harness' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const REQUESTED = 'Cancellation requested.' @@ -112,6 +113,7 @@ describe('a Stop pressed again', () => { await store.renewLeases([]) const relaunchedStore = await openTestAgentSessionRecordStore(root) const relaunched = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store: relaunchedStore, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 0cd28fafb63d..c16fa93048ba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -21,7 +21,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { claudeAndCodexDefinition } from './structured-agent-session-adapter-router-test-support' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { HOST_TEST_NOW, @@ -163,6 +163,7 @@ export async function createRestTestRig( } const hostFor = (overrides: Partial) => new StructuredAgentSessionHost({ + agents: claudeAndCodexAgents(), logger: createStructuredAgentSessionLogger(), store, adapter: { @@ -170,8 +171,7 @@ export async function createRestTestRig( releaseAcquisition: vi.fn(async () => true), cancelTurn: async () => ({ cancelled: true }), answerPrompt: async ({ commit }) => commit(), - setOption: async () => undefined, - definition: claudeAndCodexDefinition + setOption: async () => undefined }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 59d478cbb40a..cf442256bf53 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -40,6 +40,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' /** Starts the agent explicitly — the attach a client's ensure makes — for a test that needs a * running child before its next step. Nothing else starts one ahead of a send. */ @@ -132,6 +133,7 @@ export async function interruptedRestart( const clock = { now: NOW + 1 } const log = recordingProductionStructuredAgentSessionLogger() const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: log.logger, store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts index 38a47a06ebea..335d5a14686a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -29,6 +29,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -59,6 +60,7 @@ function adapter(): StructuredAgentSessionAdapter { function createHost(store: AgentSessionRecordStore): StructuredAgentSessionHost { const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index 8d5d5d54cbb8..6776a2f9e898 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -28,6 +28,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' const caller = { callerKey: 'desktop' } const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 } @@ -80,6 +81,7 @@ function adapter(): StructuredAgentSessionAdapter { function openHost(): StructuredAgentSessionHost { return new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 5ee4ca72f691..1e5f1804039d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -31,6 +31,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' const caller = { callerKey: 'desktop' } let directory: string @@ -94,6 +95,7 @@ beforeEach(async () => { closeSession: async () => true } host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index f8cc4ef26921..a435dad55ba9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -21,6 +21,7 @@ import type { StructuredAgentSessionCaller } from './structured-agent-session-ho import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' import { conversationCommandBlocked } from './structured-conversation-command-admission' import { rewindRefusal } from './structured-rewind-refusal' +import { structuredAgentRewindSupport } from './structured-agent-registry' import { persistRewindRecord, recoverStructuredRewind } from './structured-rewind-recovery' import { mergeRetainedHostLifecycleRows } from './structured-rewind-retained-host-rows' @@ -36,6 +37,7 @@ export async function rewindStructuredAgentSession( const result = await admitAndRunAgentSessionMutation({ store, adapter: context.deps.adapter, + agents: context.deps.agents, logger: context.deps.logger, callerKey: caller.callerKey, envelope: params.envelope, @@ -64,9 +66,14 @@ export async function rewindStructuredAgentSession( run: async (ctx) => { await attachContext.runtimeState.flushEventSink(sessionId) const record = store.getRecord(sessionId)! - const support = ctx.adapter.rewindSupport?.(sessionId) - if (!support?.supported) { - return rewindRefusal(support?.reason ?? 'unsupported') + const support = structuredAgentRewindSupport( + ctx.agents, + ctx.adapter, + sessionId, + ctx.agent + ) + if (!support.supported) { + return rewindRefusal(support.reason) } if ( record.rewind?.phase === 'prepared' || diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts index 55acadfc562f..a88cfc96e52b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -12,6 +12,7 @@ import { performSend, type AgentSessionTurnContext } from './structured-agent-se import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const journals = createTrackedJournalOpener() @@ -63,6 +64,8 @@ describe('structured send idempotency', () => { sessionId: 'session-1', journal, fence: 2, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { dispatch } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'caller', @@ -104,6 +107,8 @@ describe('structured send idempotency', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { dispatch } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'caller', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts index c30c33d1f434..631b9cac3c37 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts @@ -26,6 +26,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => unknown): Promise { @@ -71,6 +72,7 @@ async function relaunchAfterCrashMidTurn( throw new Error('claude: command not found') }) const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), acquire }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index c51219ae8e64..f1bbd673cd62 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -26,6 +26,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -72,6 +73,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: { warn: (_message, fields) => hostErrors.push(fields.error), error: (_message, fields) => hostErrors.push(fields.error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index 6cf15ca088c7..f41d2e190c7d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -31,6 +31,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -148,6 +149,7 @@ beforeEach(async () => { dispatch = vi.fn(async () => ({ state: 'admitted' as const })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 8298ac67daf9..2a7319c595a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -28,6 +28,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -109,6 +110,7 @@ beforeEach(async () => { dispatch = vi.fn(async () => accepted()) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), @@ -137,6 +139,7 @@ describe('settled attach retry', () => { .mockRejectedValueOnce(new Error('journal path unavailable')) .mockResolvedValue(null) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), historyFilePath }, @@ -195,6 +198,7 @@ describe('settled attach retry', () => { }) const mintSpawnToken = vi.fn(() => 'spawn-safe') host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), @@ -240,6 +244,7 @@ describe('settled attach retry', () => { let token = 0 const mintSpawnToken = vi.fn(() => `spawn-${++token}`) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), @@ -271,6 +276,7 @@ describe('settled attach retry', () => { await host.flushAllStreamedEvents() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), @@ -329,6 +335,7 @@ describe('settled attach retry', () => { await host.flushAllStreamedEvents() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 0afc0a05077d..77b48a375a47 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -29,6 +29,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -122,6 +123,7 @@ beforeEach(async () => { ) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index b71022b92c70..afd28054d744 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -30,6 +30,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const writes = vi.hoisted(() => ({ failing: false })) @@ -84,6 +85,7 @@ async function relaunch( // The lease-reconcile entries the host logs, by the failure each reports. const leaseReconcileLogged = vi.fn() const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: { warn: (_message, fields) => { if (fields.scope === 'lease-reconcile') { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index cce95e1ac0b9..de67edded0ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -42,6 +42,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'Codex stopped while this response was in progress. You can continue in this conversation.' @@ -77,6 +78,7 @@ function openHost( probeOwner?: (record: AgentSessionRecord) => Promise ): void { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: { warn: (_message, fields) => hostErrors.push(fields.error), error: (_message, fields) => hostErrors.push(fields.error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index 5a95d7c90b21..2c45cc73a416 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -16,10 +16,9 @@ import { threadGoalPlan } from './structured-agent-session-thread-goal' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' -import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -68,15 +67,19 @@ function appendGoalRow( function context( journal: AgentSessionJournal, - adapter: Partial + double: Partial, + agent = 'codex' ): AgentSessionTurnContext { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the goal path reads only the goal methods. + const adapter = double as StructuredAgentSessionAdapter return { logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the goal path reads only the goal methods. - adapter: adapter as StructuredAgentSessionAdapter, + adapter, + agents: claudeAndCodexAgents(adapter), + agent, persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), @@ -91,8 +94,7 @@ describe('performThreadGoalChange', () => { const result = await performThreadGoalChange( context(journal, { - changeThreadGoal, - capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + changeThreadGoal }), { clientOperationId: 'op-1', change: { kind: 'set', objective: 'Ship the parser' } } ) @@ -121,8 +123,7 @@ describe('performThreadGoalChange', () => { it('removes the objective when the provider refuses the goal', async () => { const journal = await openJournal() const ctx = context(journal, { - changeThreadGoal: async () => ({ ok: false, rejected: 'goals feature is disabled' }), - capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + changeThreadGoal: async () => ({ ok: false, rejected: 'goals feature is disabled' }) }) const result = await performThreadGoalChange(ctx, { @@ -146,8 +147,7 @@ describe('performThreadGoalChange', () => { const ctx = context(journal, { changeThreadGoal: async () => { throw new Error('connection closed') - }, - capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + } }) await expect( @@ -168,8 +168,7 @@ describe('performThreadGoalChange', () => { async () => ({ ok: true as const }) ] const ctx = context(journal, { - changeThreadGoal: () => attempts.shift()!(), - capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + changeThreadGoal: () => attempts.shift()!() }) const input = { clientOperationId: 'op-9', @@ -198,8 +197,7 @@ describe('performThreadGoalChange', () => { const journal = await openJournal() const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) const ctx = context(journal, { - changeThreadGoal, - capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + changeThreadGoal }) await performThreadGoalChange(ctx, { @@ -216,13 +214,10 @@ describe('performThreadGoalChange', () => { const journal = await openJournal() const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - const result = await performThreadGoalChange( - context(journal, { - changeThreadGoal, - capabilities: () => CLAUDE_STRUCTURED_AGENT.capabilities - }), - { clientOperationId: 'op-5', change: { kind: 'set', objective: 'Ship it' } } - ) + const result = await performThreadGoalChange(context(journal, { changeThreadGoal }, 'claude'), { + clientOperationId: 'op-5', + change: { kind: 'set', objective: 'Ship it' } + }) expect(result).toMatchObject({ ok: false, @@ -244,8 +239,7 @@ describe('performThreadGoalChange', () => { await appendGoalRow(journal, { status: 'complete' }) const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) const ctx = context(journal, { - changeThreadGoal, - capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + changeThreadGoal }) await performThreadGoalChange(ctx, { @@ -277,8 +271,7 @@ describe('performThreadGoalChange', () => { const journal = await openJournal() const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) const ctx = context(journal, { - changeThreadGoal, - capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + changeThreadGoal }) // Issued, not yet landed: the set's read takes its place behind it in the journal's queue. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index 1ab4e5cfaa6e..0948a86e8fd6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -47,7 +47,7 @@ export async function performThreadGoalChange( ctx: AgentSessionTurnContext, input: { clientOperationId: string; change: AgentSessionThreadGoalChange } ): Promise> { - if (!ctx.adapter.changeThreadGoal || !ctx.adapter.capabilities?.(ctx.sessionId)?.threadGoal) { + if (!ctx.adapter.changeThreadGoal || !ctx.agents.capabilities(ctx.agent)?.threadGoal) { return refused('goalsUnsupported', 'Goals are unavailable for this chat session.') } const { change } = input diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 0cbf5b8ce6c1..b79191c9ac39 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -13,6 +13,7 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item- import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { testEventSinkLogging } from './structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -58,6 +59,8 @@ describe('performCancel', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { cancelTurn } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'client-1', @@ -112,6 +115,8 @@ describe('performCancel', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { cancelTurn } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'client-1', @@ -157,6 +162,8 @@ describe('performCancel', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { cancelTurn: vi.fn(async () => ({ cancelled: false })) } as unknown as StructuredAgentSessionAdapter, @@ -191,6 +198,8 @@ describe('performCancel', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { cancelTurn, stopBackgroundTasks } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'client-1', @@ -229,6 +238,8 @@ describe('performCancel', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { cancelTurn, stopBackgroundTasks } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'client-1', @@ -310,6 +321,8 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { acquire: vi.fn(), dispatch: vi.fn(), @@ -393,6 +406,8 @@ describe('the note a Stop writes', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { acquire: vi.fn(), dispatch: vi.fn(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 629d7e717903..49bf40eec6f1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -43,6 +43,7 @@ import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { StructuredAgentRegistry } from './structured-agent-registry' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' export { performCancel } from './structured-agent-session-turns-cancel' @@ -52,6 +53,10 @@ export type AgentSessionTurnContext = { journal: AgentSessionJournal fence: number adapter: StructuredAgentSessionAdapter + /** What each agent declares; the session's own answer is `agents.capabilities(agent)`. */ + agents: StructuredAgentRegistry + /** The session's agent. */ + agent: string logger: StructuredAgentSessionLogger persistedOptions?: Readonly> persistOptions: (options: Readonly>) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts index 20c5abd595e8..8f6037c6d9c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -26,6 +26,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => unknown): Promise { @@ -85,6 +86,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 242f7c228778..768b6724caa2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -55,6 +55,7 @@ import { claudeProviderHandle, codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const DEAD_OWNER: AgentSessionProcessIdentity = { @@ -126,6 +127,7 @@ async function seedStore(record: PersistedAgentSessionRecord): Promise { /** Every recorded owner in these fixtures is long gone; that is the present-time evidence. */ function openHost(overrides: Partial = {}): void { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 828128bc2b71..d2cb969bf1d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -9,7 +9,7 @@ import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { claudeAndCodexDefinition } from './structured-agent-session-adapter-router-test-support' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' import { AgentSessionAcquisitionRefusal, type StructuredAgentSessionAdapter @@ -67,6 +67,7 @@ let ownerProbe: AgentSessionOwnerProbe = { outcome: 'pid-absent' } async function openHost(): Promise { store = await openTestAgentSessionRecordStore(generationRoot()) host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter, @@ -102,7 +103,6 @@ beforeEach(async () => { compact.mockReset().mockResolvedValue({ state: 'accepted', providerIdentity: null }) directory = await mkdtemp(join(tmpdir(), 'orca-conversation-command-')) adapter = { - definition: claudeAndCodexDefinition, supportsLocation: (location) => location.executionHostId === 'local' && location.wslDistro === null, acquire: vi.fn(async (input) => { diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index a466d00d2b5e..9672df6a4879 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -106,6 +106,7 @@ export function runStructuredConversationCommand( return admitAndRunAgentSessionMutation({ store, adapter: context.deps.adapter, + agents: context.deps.agents, logger: context.deps.logger, callerKey: caller.callerKey, envelope, diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 41031cb3b3eb..6113b17ddec6 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -24,8 +24,12 @@ import { attach, CALLER, envelope, - hostTestState + hostTestState, + replaceHostTestState } from './structured-agent-session-host-test-harness' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { StructuredAgentRegistry } from './structured-agent-registry' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' import { HOST_TEST_NOW, HOST_TEST_SESSION as SESSION, @@ -333,6 +337,33 @@ it('says only that the compaction failed when the provider refused it without wo ) }) +it('refuses the command for an agent that does not declare compaction, whatever its adapter has', async () => { + const declared = CODEX_STRUCTURED_AGENT.capabilities + const agents = new StructuredAgentRegistry([ + { + definition: { + ...CODEX_STRUCTURED_AGENT, + capabilities: { ...declared, compact: false, threadGoal: false, rewind: false } + }, + adapter: state.host.deps.adapter + } + ]) + replaceHostTestState({ + store: state.store, + host: new StructuredAgentSessionHost({ ...state.host.deps, agents }) + }) + state = hostTestState() + await attach() + const params = compactParams() + + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + ok: true, + value: { state: 'completed', failure: { kind: 'commandRefused' } } + }) + expect(compact).not.toHaveBeenCalled() + expect(await commandTurn(params.envelope.clientOperationId)).toBeUndefined() +}) + it('refuses the command at handover when the provider opened a turn meanwhile (B3)', async () => { await attach() const events = state.acquire.mock.calls.at(-1)?.[0].events diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 1caa929185b6..cb360070191e 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -220,10 +220,10 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR location, provider: input.agent, agent: input.agent, - accountHome: { - variable: input.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', - path: adoption ? adoption.accountHomePath : selectedAccountHomePath - }, + accountHome: agentSessionAccountHome( + input.agent, + adoption ? adoption.accountHomePath : selectedAccountHomePath + ), ...(options ? { options } : {}), ...(input.resumeFrom && adoption ? { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 1992d3c785a0..f1faf8e1b388 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -16,6 +16,7 @@ import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const SESSION = 'session-adoption-replay' const THREAD = 'thread-adoption-replay' @@ -187,6 +188,7 @@ describe('committed adopting create RPC replay', () => { const store = await openTestAgentSessionRecordStore(root) const sessionAdapter = adapter() host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 44e0e74ab667..9ff3979b5586 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -33,8 +33,7 @@ import { openTestJournalHostDatabase, updateTestJournalRowJson } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' -import { CODEX_STRUCTURED_AGENT } from '../../../codex/codex-structured-agent-definition' -import { CLAUDE_STRUCTURED_AGENT } from '../../../claude/claude-structured-agent-definition' +import { claudeAndCodexDeclared } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const CLIENT = { clientId: 'device-1', @@ -372,11 +371,9 @@ describe('options at rest', () => { it('answers the provider-level features of a chat at rest (P2-17)', async () => { await restingChat() + // A runtime that declares Codex's goal and rewind, as production registers it. + setStructuredAgentSessionHost(await rig.restart({ agents: claudeAndCodexDeclared() })) Object.assign(rig.host.deps.adapter, { - capabilities: (_id: string, agent?: string) => - agent === 'codex' - ? CODEX_STRUCTURED_AGENT.capabilities - : CLAUDE_STRUCTURED_AGENT.capabilities, rewindSupport: (_id: string, agent?: string) => agent === 'codex' ? { supported: true } : { supported: false, reason: 'unsupported' } }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 7b13887e1aa4..549b0a92dd26 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -31,6 +31,7 @@ import { agentSessionFailureWords } from '../../../../shared/agent-session-failu import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const CONNECTION = 'connection-1' const CLIENT = { @@ -81,6 +82,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index 6100749b7cc1..0901f6ffa5d4 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -41,6 +41,7 @@ import { closeStructuredAgentSessionChild } from '../../structured-agent-session import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const WORKTREE = `id:${HOST_TEST_LOCATION.workspaceId}` const SOURCE_TAB = `structured-agent-session-${HOST_TEST_SESSION}` @@ -95,6 +96,7 @@ function providerAdapter(): StructuredAgentSessionAdapter { async function openHost(): Promise { store = await openTestAgentSessionRecordStore(directory) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: providerAdapter(), diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 49665fd27c39..8fbd90faca79 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -34,6 +34,7 @@ import { type StructuredAgentSessionHostDeps } from '../native-chat/agent-session-wire/structured-agent-session-host' import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' +import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { readClaudeManagedAccountGateSettings, @@ -303,18 +304,17 @@ async function installOnJournal( ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), modelCatalog: agentModelCatalogStore }) - const adapter = new StructuredAgentSessionAdapterRouter( - [ - { definition: CODEX_STRUCTURED_AGENT, adapter: codex }, - { definition: CLAUDE_STRUCTURED_AGENT, adapter: claude } - ], - async () => { - await Promise.all([codex.closeAll(), claude.closeAll()]) - } - ) + const agents = new StructuredAgentRegistry([ + { definition: CODEX_STRUCTURED_AGENT, adapter: codex }, + { definition: CLAUDE_STRUCTURED_AGENT, adapter: claude } + ]) + const adapter = new StructuredAgentSessionAdapterRouter(agents, async () => { + await Promise.all([codex.closeAll(), claude.closeAll()]) + }) host = new StructuredAgentSessionHost({ store, adapter, + agents, recoveryCapsule: new AgentSessionRecoveryCapsule(deps.stateDirectory), journalDatabase, claimKeyId: deps.claimKeyId, diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index 4e29e628d36d..52649785c59f 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -11,7 +11,8 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import type { AgentSessionRecord } from '../../shared/agent-session-record' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' import { fakeClaude } from '../claude/claude-structured-session-test-support' -import { claudeAndCodexRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { @@ -122,11 +123,13 @@ beforeEach(async () => { onLifecycleEvent: () => {} }) log = recordingStructuredAgentSessionLogger() + // Only Claude sessions are attached here; the router supplies the production create gate. + const agents = claudeAndCodexAgents({ claude: adapter, codex: adapter }) host = new StructuredAgentSessionHost({ + agents, logger: log.logger, store, - // Only Claude sessions are attached here; the router supplies the production create gate. - adapter: claudeAndCodexRouter({ claude: adapter, codex: adapter }, () => adapter.closeAll()), + adapter: new StructuredAgentSessionAdapterRouter(agents, () => adapter.closeAll()), journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', now: () => HOST_TEST_NOW, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx index fcb474f2acc6..b3f4a938fb25 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx @@ -94,4 +94,19 @@ describe('useStructuredAgentSession transcript', () => { // The list places rows by the same items, so the notice joins the cut turn. expect(result.current.journalItems.at(-1)).toMatchObject({ turnScope: SCOPE }) }) + + it('names any other agent by its own label, never as Claude', () => { + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'gemini', + target: { kind: 'local' }, + isVisible: true + }) + ) + + expect(result.current.messages.at(-1)).toMatchObject({ + blocks: [{ text: expect.stringMatching(/^Gemini stopped while/) }] + }) + }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 839a384fa441..e8d0efdd7278 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -39,7 +39,6 @@ import { outboxOutsideQueuedCards } from './structured-agent-session-queued-card import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' -import { TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-names' export type { StructuredPromptItem } from './structured-agent-session-message-projection' @@ -170,7 +169,7 @@ export function useStructuredAgentSession(args: { const transcriptItems = useMemo( () => withNativeChatCutTurnNotices(transportState.journalItems, { - agentName: TUI_AGENT_DISPLAY_NAMES[agent === 'codex' ? 'codex' : 'claude'] + agentName: structuredAgentLabel(agent) }), [agent, transportState.journalItems] ) diff --git a/src/shared/agent-session-turn-record.test.ts b/src/shared/agent-session-turn-record.test.ts index bcd14a7290fd..d31a36d1c764 100644 --- a/src/shared/agent-session-turn-record.test.ts +++ b/src/shared/agent-session-turn-record.test.ts @@ -110,6 +110,22 @@ describe('legacyAgentJournalTurnStatusBody', () => { ).toBe('Codex turn outcome unverifiable') }) + it('names any other agent by its own name, never as Codex', () => { + expect(legacyAgentJournalTurnStatusBody(turn, 'orca:command-turn:t1', 'gemini').text).toBe( + 'Gemini turn completed' + ) + expect(legacyAgentJournalTurnStatusBody(turn, 'legacy:gemini:s:turn-lifecycle%3At1').text).toBe( + 'Gemini turn completed' + ) + // Nothing names the agent: say so rather than guess one. + expect(legacyAgentJournalTurnStatusBody(turn, 'orca:command-turn:t1').text).toBe( + 'Agent turn completed' + ) + expect(legacyAgentJournalTurnStatusBody(turn, 'orca:command-turn:t1', 'codex').text).toBe( + 'Codex turn completed' + ) + }) + it('carries the verdict to a client that predates the turn item', () => { // The downgrade is the only carrier an old client gets. Its own text still // reads "completed" — that copy is the pre-existing contract and this change diff --git a/src/shared/agent-session-turn-record.ts b/src/shared/agent-session-turn-record.ts index 2932eef5c1fc..eb4b6c49a56a 100644 --- a/src/shared/agent-session-turn-record.ts +++ b/src/shared/agent-session-turn-record.ts @@ -11,6 +11,7 @@ import { type AgentJournalTurnOutcome } from './agent-session-journal-types' import { agentTurnLifecycleText } from './agent-turn-lifecycle-text' +import { tuiAgentDisplayName } from './tui-agent-display-names' export function readAgentJournalTurn( body: AgentJournalItemBody | undefined @@ -67,9 +68,7 @@ export function legacyAgentJournalTurnStatusBody( itemId: string, sessionAgent?: string | null ): AgentJournalStatusItem { - const agent = - (sessionAgent ?? (itemId.startsWith('legacy:claude:') ? 'claude' : 'codex')) === 'claude' - ? 'Claude' - : 'Codex' - return { kind: 'status', text: agentTurnLifecycleText(agent, turn.state), turnLifecycle: turn } + const agent = sessionAgent ?? /^legacy:([^:]+):/.exec(itemId)?.[1] ?? null + const name = agent === null ? 'Agent' : (tuiAgentDisplayName(agent) ?? agent) + return { kind: 'status', text: agentTurnLifecycleText(name, turn.state), turnLifecycle: turn } } diff --git a/src/shared/agent-turn-lifecycle-text.ts b/src/shared/agent-turn-lifecycle-text.ts index f8bc2be568b5..3f641f27ad96 100644 --- a/src/shared/agent-turn-lifecycle-text.ts +++ b/src/shared/agent-turn-lifecycle-text.ts @@ -3,7 +3,7 @@ import type { AgentJournalTurnLifecycleState } from './agent-session-journal-typ /** Fallback text on a lifecycle row for readers that render status text raw. * Must never overstate what the host knows: an unobserved end is not "completed". */ export function agentTurnLifecycleText( - agent: 'Claude' | 'Codex', + agent: string, state: AgentJournalTurnLifecycleState ): string { switch (state) { diff --git a/src/shared/tui-agent-display-names.ts b/src/shared/tui-agent-display-names.ts index a780cff5a4e9..0ea071e4e17d 100644 --- a/src/shared/tui-agent-display-names.ts +++ b/src/shared/tui-agent-display-names.ts @@ -56,3 +56,8 @@ export const TUI_AGENT_DISPLAY_NAMES: Record = { /** Canonical agent id list derived from the exhaustive display-name record, * so shared modules can enumerate agents without importing renderer code. */ export const ALL_TUI_AGENTS = Object.keys(TUI_AGENT_DISPLAY_NAMES) as readonly TuiAgent[] + +/** The display name of `agent`, or null when it names no TUI agent. */ +export function tuiAgentDisplayName(agent: string): string | null { + return Object.entries(TUI_AGENT_DISPLAY_NAMES).find(([id]) => id === agent)?.[1] ?? null +} diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index d427eb5109a8..f1076ac1df4d 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -65,6 +65,7 @@ import { import { openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' // Why: a cold CI run extracts the baseline checkout before the first pairing. const SUITE_TIMEOUT_MS = 180_000 @@ -550,6 +551,7 @@ describe('cross-version structured agent sessions', () => { root = await mkdtemp(join(tmpdir(), 'orca-cross-version-ai-vault-')) store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { @@ -777,6 +779,7 @@ describe('cross-version structured agent sessions', () => { async function bootHost(generation: string): Promise { store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index abf2f994004a..9fc2255514b2 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -27,6 +27,7 @@ import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-ty import { openTestJournalHostDatabase } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../src/shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const WORKTREE = 'repo-1::/workspace/repo' @@ -38,6 +39,7 @@ let closeSession: Mock Date: Mon, 5 Oct 2026 03:12:32 -0700 Subject: [PATCH 146/269] Read ACP permissions, session events and prompt errors through the protocol client's own types The translator now reads a permission request with the client's lenient reader, a session update with its session-event reader, and takes only the agent's own error answer as a failed prompt's reason, so an Orca-side error never reads as the provider's words. Tests cover protocol values newer than this build. --- src/main/acp/acp-dialects/acp-dialect.ts | 3 +- src/main/acp/acp-dialects/grok-dialect.ts | 4 +- src/main/acp/acp-timeline-generic.test.ts | 5 +- src/main/acp/acp-timeline-open-enums.test.ts | 104 ++++++++++++++++++ src/main/acp/acp-timeline-requests.ts | 9 +- src/main/acp/acp-timeline-routing.test.ts | 5 +- src/main/acp/acp-timeline-translator.ts | 25 ++--- .../acp/acp-timeline-turn-failures.test.ts | 7 +- src/main/acp/acp-turn-failures.ts | 7 +- 9 files changed, 138 insertions(+), 31 deletions(-) create mode 100644 src/main/acp/acp-timeline-open-enums.test.ts diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 4697189a20ee..3b38b9cc1bdd 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -3,6 +3,7 @@ import type { AgentJournalToolCallItem } from '../../../shared/agent-session-jou import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { NativeChatBackgroundTaskBlock } from '../../../shared/native-chat-types' import type { ProviderTimelineRequestBody } from '../../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpAgentError } from '../acp-errors' import type { ToolCallUpdate } from '../generated/acp-protocol.generated' export type AcpRequestPresentation = { @@ -45,7 +46,7 @@ export type AcpDialect = { contextWindow?(models: unknown): number | undefined request?(method: string, params: unknown): AcpRequestPresentation | undefined /** The provider's words in a `session/prompt` error answer, when its message is generic. */ - promptErrorDetail?(error: unknown): string | undefined + promptErrorDetail?(error: AcpAgentError): string | undefined /** The row for a failed turn the provider gave no words for. */ failedTurnText?(stopReason: string): string } diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index b47f899f30ee..09213c973894 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -28,7 +28,7 @@ const promptCompleteSchema = z.looseObject({ stopReason: z.string(), agentResult: z.string().nullish() }) -const promptErrorSchema = z.looseObject({ data: z.looseObject({ message: z.string() }) }) +const promptErrorDataSchema = z.looseObject({ message: z.string() }) /** Grok writes its result text as the failure's reason only for these ends. */ const FAILED_STOP_REASONS = ['error', 'rate_limit'] @@ -176,7 +176,7 @@ export const GROK_ACP_DIALECT: AcpDialect = { toolBackgroundTasks: grokToolBackgroundTasks, notification, contextWindow, - promptErrorDetail: (error) => promptErrorSchema.safeParse(error).data?.data.message, + promptErrorDetail: (error) => promptErrorDataSchema.safeParse(error.data).data?.message, failedTurnText: (stopReason) => stopReason === 'rate_limit' ? 'Grok usage limit reached.' diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts index 4a041b3ad3ae..9d08552577fe 100644 --- a/src/main/acp/acp-timeline-generic.test.ts +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -7,6 +7,7 @@ import { openProviderTimelineRig } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { AcpAgentError } from './acp-errors' import { AcpTimelineTranslator, acpTurnEnd } from './acp-timeline-translator' afterEach(closeProviderTimelineRigs) @@ -177,9 +178,9 @@ describe('generic ACP translation', () => { apply(translator.openPrompt('send-1', 1000).events) apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1100)) apply(translator.openPrompt('send-2', 1200).events) - apply(translator.promptFailed('send-2', { code: -32603, message: 'Upstream failed' }, 1300)) + apply(translator.promptFailed('send-2', new AcpAgentError(-32603, 'Upstream failed'), 1300)) apply(translator.openPrompt('send-3', 1400).events) - apply(translator.promptFailed('send-3', { code: -32603 }, 1500)) + apply(translator.promptFailed('send-3', new AcpAgentError(-32603, ''), 1500)) const rows = (await rig.rows()).filter((row) => row.body.kind === 'status') expect(rows.map((row) => row.body)).toEqual([ { kind: 'status', tone: 'error', text: 'Upstream failed' }, diff --git a/src/main/acp/acp-timeline-open-enums.test.ts b/src/main/acp/acp-timeline-open-enums.test.ts new file mode 100644 index 000000000000..08907152afa0 --- /dev/null +++ b/src/main/acp/acp-timeline-open-enums.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { AcpTimelineTranslator } from './acp-timeline-translator' + +afterEach(closeProviderTimelineRigs) + +async function genericRig() { + const rig = await openProviderTimelineRig() + const translator = new AcpTimelineTranslator({ sessionId: 'provider-1' }) + const apply = (events: ProviderTimelineEvent[]) => { + for (const event of events) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + const update = (update: unknown) => + apply(translator.notification('session/update', { sessionId: 'provider-1', update }, 1100)) + return { rig, translator, apply, update } +} + +// The protocol's enums are open: a value newer than this build reads as known and falls back. +describe('ACP values newer than this build', () => { + it('keeps a tool with a newer kind or status as a tool row in its last known state', async () => { + const { rig, translator, apply, update } = await genericRig() + apply(translator.openPrompt('send-1', 1000).events) + update({ + sessionUpdate: 'tool_call', + toolCallId: 't1', + title: 'Open page', + kind: 'browse', + status: 'pending' + }) + update({ sessionUpdate: 'tool_call_update', toolCallId: 't1', status: 'paused' }) + const running = (await rig.rows()).find((row) => row.body.kind === 'tool-call')?.body + expect(running).toMatchObject({ name: 'Open page', state: 'running' }) + update({ sessionUpdate: 'tool_call_update', toolCallId: 't1', status: 'completed' }) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) + const rows = (await rig.rows()).filter((row) => row.body.kind !== 'turn') + expect(rows.map((row) => row.body)).toEqual([ + expect.objectContaining({ kind: 'tool-call', state: 'completed' }) + ]) + }) + + it('reads a plan entry with a newer status or priority as not done', async () => { + const { rig, translator, apply, update } = await genericRig() + apply(translator.openPrompt('send-1', 1000).events) + update({ + sessionUpdate: 'plan', + entries: [{ content: 'Wait', status: 'blocked', priority: 'urgent' }] + }) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) + expect( + (await rig.rows()).filter((row) => row.body.kind !== 'turn').map((row) => row.body) + ).toEqual([expect.objectContaining({ presentation: 'plan-document', text: '- [ ] Wait' })]) + }) + + it('ends a turn with a newer stop reason without inventing an outcome', async () => { + const { rig, translator, apply } = await genericRig() + apply(translator.openPrompt('send-1', 1000).events) + apply(translator.promptResult('send-1', { stopReason: 'paused_for_review' }, 1200)) + const [turn] = await rig.turns() + expect(turn).toMatchObject({ state: 'completed' }) + expect(turn).not.toHaveProperty('outcome') + expect((await rig.rows()).filter((row) => row.body.kind === 'status')).toEqual([]) + }) + + it('shows a permission the runtime could only partly read, with what it could read', async () => { + const { rig, translator, apply } = await genericRig() + apply(translator.openPrompt('send-1', 1000).events) + const request = translator.request( + 'session/request_permission', + { + sessionId: 'provider-1', + toolCall: { toolCallId: 't1', title: 42 }, + options: [{ optionId: 'allow', kind: 'allow_for_project' }, { name: 'no id' }] + }, + 7 + ) + apply(request.events) + expect(request.presentation?.body).toMatchObject({ + kind: 'approval', + title: 'Permission requested', + options: [{ id: 'allow', label: 'allow' }] + }) + expect(request.presentation?.reply({ kind: 'option', optionId: 'allow' })).toEqual({ + outcome: { outcome: 'selected', optionId: 'allow' } + }) + expect((await rig.rows()).map((row) => row.body.kind)).toEqual(['turn', 'approval']) + expect(() => + translator.request( + 'session/request_permission', + { + sessionId: 'provider-1', + toolCall: {}, + options: [{ optionId: 'allow', kind: 'allow_once' }] + }, + 8 + ) + ).toThrow('Invalid ACP permission request') + }) +}) diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts index b60e860d2cda..aa7e396c1368 100644 --- a/src/main/acp/acp-timeline-requests.ts +++ b/src/main/acp/acp-timeline-requests.ts @@ -3,7 +3,7 @@ import type { ProviderTimelineEvent } from '../native-chat/agent-session-timelin import type { AcpDialect, AcpRequestPresentation } from './acp-dialects/acp-dialect' import type { AcpToolTimeline } from './acp-tool-timeline' import { AcpRpcError } from './acp-errors' -import { RequestPermissionRequestSchema } from './generated/acp-protocol.generated' +import { readAcpPermissionRequest } from './acp-permission-requests' export const pendingAcpResolution = { state: 'pending', @@ -13,11 +13,12 @@ export const pendingAcpResolution = { } as const export function acpPermissionPresentation(params: unknown): AcpRequestPresentation { - const parsed = RequestPermissionRequestSchema.safeParse(params) - if (!parsed.success) { + // The runtime already read this request and reported any field it dropped. + const request = readAcpPermissionRequest(params, () => {}) + if (!request) { throw new AcpRpcError(-32602, 'Invalid ACP permission request') } - const { toolCall, options } = parsed.data + const { toolCall, options } = request return { body: { kind: 'approval', diff --git a/src/main/acp/acp-timeline-routing.test.ts b/src/main/acp/acp-timeline-routing.test.ts index d5c12540c7bc..4ea6c30df29d 100644 --- a/src/main/acp/acp-timeline-routing.test.ts +++ b/src/main/acp/acp-timeline-routing.test.ts @@ -4,6 +4,7 @@ import { messageText } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' +import { AcpAgentError } from './acp-errors' const chunk = (promptId: string, text: string) => ({ sessionId: 'session-1', @@ -101,7 +102,7 @@ describe('ACP exact prompt identity', () => { f.apply( lane.notification('_x.ai/session_notification', done(prompt.promptId, stopReason), 1002) ) - f.apply(lane.promptFailed('c1', new Error('Provider rejected prompt'), 1003)) + f.apply(lane.promptFailed('c1', new AcpAgentError(-32603, 'Provider rejected prompt'), 1003)) const next = lane.openPrompt('c2', 1004) f.apply(next.events) f.apply(lane.notification('session/update', chunk(next.promptId, 'next'), 1005)) @@ -115,7 +116,7 @@ describe('ACP exact prompt identity', () => { const f = await openAcpFixtureRig() const lane = f.lane() lane.openPrompt('c1', 1000) - f.apply(lane.promptFailed('c1', { code: -32603, message: 'Rejected' }, 1001)) + f.apply(lane.promptFailed('c1', new AcpAgentError(-32603, 'Rejected'), 1001)) expect((await f.rig.turns())[0]).toMatchObject({ state: 'completed', outcome: 'failure' }) expect(() => lane.openPrompt('c2', 1002)).not.toThrow() }) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 2610d1de533f..3794b0410460 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -8,19 +8,16 @@ import { type AcpDialect, type AcpRequestPresentation } from './acp-dialects/acp-dialect' +import type { AcpAgentError } from './acp-errors' import { AcpHistoryAdoption } from './acp-history-adoption' import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' -import type { AcpSessionEvent } from './acp-session-runtime' +import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' import { translateAcpRequest } from './acp-timeline-requests' import { acpSessionUpdate } from './acp-session-update' import { AcpToolTimeline } from './acp-tool-timeline' import { AcpTurnFailures, acpPromptErrorDetail } from './acp-turn-failures' import { AcpTurnMessages } from './acp-turn-messages' -import { - SessionNotificationSchema, - type PromptResponse, - type SessionNotification -} from './generated/acp-protocol.generated' +import type { PromptResponse, SessionNotification } from './generated/acp-protocol.generated' export { acpTurnEnd } from './acp-prompt-turns' @@ -88,7 +85,9 @@ export class AcpTimelineTranslator { return this.finishPrompt(clientMessageId, result.stopReason, at) } - promptFailed(clientMessageId: string, error: unknown, at: number): ProviderTimelineEvent[] { + /** The agent's own error answer to the prompt; Orca's errors about it (a timeout, an unreadable + * answer, a closed connection) are no provider words and never reach here. */ + promptFailed(clientMessageId: string, error: AcpAgentError, at: number): ProviderTimelineEvent[] { const detail = acpPromptErrorDetail(this.dialect, error) const ended = this.prompts.last if (this.prompts.current?.clientMessageId !== clientMessageId) { @@ -161,9 +160,9 @@ export class AcpTimelineTranslator { return [] } const envelope = acpNotificationEnvelopeSchema.safeParse(params) - const standard = - method === 'session/update' ? SessionNotificationSchema.safeParse(params) : undefined - const update = standard?.success ? standard.data.update : undefined + const read = method === 'session/update' ? readAcpSessionEvent(params) : null + const standard = read?.kind === 'known' ? read.notification : undefined + const update = standard?.update const markedReplay = (envelope.success && envelope.data._meta?.isReplay === true) || extension?.replay === true const history = markedReplay || (extension?.replay === undefined && this.load !== undefined) @@ -215,15 +214,15 @@ export class AcpTimelineTranslator { } return events } - if (standard?.success) { + if (standard) { // History text is keyed by its position so a re-run of the adoption lands on the same rows. const messageKey = turn && (this.dialect.injectedPromptIdentity || history) - ? this.messages.key(turn, standard.data.update) + ? this.messages.key(turn, standard.update) : undefined return [ ...events, - ...acpSessionUpdate(standard.data, turn, at, { + ...acpSessionUpdate(standard, turn, at, { history, tools: this.tools, dialect: this.dialect, diff --git a/src/main/acp/acp-timeline-turn-failures.test.ts b/src/main/acp/acp-timeline-turn-failures.test.ts index 54e50405582c..801b85fd9fb8 100644 --- a/src/main/acp/acp-timeline-turn-failures.test.ts +++ b/src/main/acp/acp-timeline-turn-failures.test.ts @@ -6,6 +6,7 @@ import { providerTurnItemId } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' +import { AcpAgentError } from './acp-errors' afterEach(closeProviderTimelineRigs) @@ -38,7 +39,7 @@ const promptComplete = (promptId: string, stopReason: string, agentResult: strin stopReason, agentResult }) -const rpcError = { code: -32603, message: 'Internal error', data: { message: REASON } } +const rpcError = new AcpAgentError(-32603, 'Internal error', { message: REASON }) function statusRows(rows: AgentJournalRenderItem[]) { return rows.flatMap((row) => (row.body.kind === 'status' ? [{ row, body: row.body }] : [])) @@ -143,7 +144,7 @@ describe('a failed ACP turn says why', () => { f.apply( lane.promptFailed( 'c1', - source === 'prompt error answer' ? rpcError : { code: -32603, message: '' }, + source === 'prompt error answer' ? rpcError : new AcpAgentError(-32603, ''), 1005 ) ) @@ -159,7 +160,7 @@ describe('a failed ACP turn says why', () => { f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) f.apply(lane.notification('_x.ai/session_notification', retryFailed, 1002)) f.apply(lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error'), 1003)) - f.apply(lane.promptFailed('c1', { code: -32603, message: 'Internal error' }, 1004)) + f.apply(lane.promptFailed('c1', new AcpAgentError(-32603, 'Internal error'), 1004)) const failures = statusRows(await f.rig.rows()) expect(failures.map((failure) => failure.body.text)).toEqual([REASON]) }) diff --git a/src/main/acp/acp-turn-failures.ts b/src/main/acp/acp-turn-failures.ts index cc2998945a1d..f4266b1704b4 100644 --- a/src/main/acp/acp-turn-failures.ts +++ b/src/main/acp/acp-turn-failures.ts @@ -1,10 +1,9 @@ -import { z } from 'zod' import { providerDiagnostic } from '../../shared/agent-session-failure' import { BoundedMap } from '../../shared/bounded-map' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect } from './acp-dialects/acp-dialect' +import type { AcpAgentError } from './acp-errors' -const promptErrorSchema = z.looseObject({ message: z.string() }) /** Ends the provider failed, rather than ones it chose (a refusal, a token limit). */ const FAILED_STOP_REASONS = ['error', 'rate_limit'] @@ -13,8 +12,8 @@ function acpStopReasonFailed(stopReason: string): boolean { } /** The provider's words in its error answer to `session/prompt`. */ -export function acpPromptErrorDetail(dialect: AcpDialect, error: unknown): string | undefined { - return dialect.promptErrorDetail?.(error) ?? promptErrorSchema.safeParse(error).data?.message +export function acpPromptErrorDetail(dialect: AcpDialect, error: AcpAgentError): string { + return dialect.promptErrorDetail?.(error) ?? error.message } /** One error row per failed turn, in the provider's own words, as a Codex turn-ending error reads: From c5aaee8ede97a4c9103b53e10cf87a21c5631dba Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:18:49 -0700 Subject: [PATCH 147/269] refactor(agent-session): the router applies the declared rewind itself The router holds the registry, so its rewindSupport answers the owner's declared rewind narrowed by the adapter, in one place no reader can bypass. The host-side combining helper is gone; readers ask the adapter they hold. --- .../structured-agent-registry.ts | 14 ------------ ...nt-session-adapter-router-registry.test.ts | 22 +++++++++++-------- ...structured-agent-session-adapter-router.ts | 4 ++-- .../structured-agent-session-adapter.ts | 2 +- .../structured-agent-session-options-read.ts | 10 ++++----- .../structured-agent-session-rewind.ts | 12 +++------- 6 files changed, 24 insertions(+), 40 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-registry.ts b/src/main/native-chat/agent-session-wire/structured-agent-registry.ts index 21e29c008ab7..c6f305548ce9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-registry.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-registry.ts @@ -5,7 +5,6 @@ // declared capability always has the adapter method behind it. import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' -import type { AgentSessionRewindSupport } from '../../../shared/agent-session-rewind' import type { StructuredAgentDefinition } from './structured-agent-definition' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' @@ -71,16 +70,3 @@ export class StructuredAgentRegistry { return this.definition(agent)?.capabilities ?? null } } - -/** Whether this session can rewind now: its agent's declaration, narrowed by the adapter. */ -export function structuredAgentRewindSupport( - agents: StructuredAgentRegistry, - adapter: Pick, - sessionId: string, - agent: string -): AgentSessionRewindSupport { - if (!agents.capabilities(agent)?.rewind) { - return { supported: false, reason: 'unsupported' } - } - return adapter.rewindSupport?.(sessionId, agent) ?? { supported: true } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts index a0564157810b..6ebb9bdcd79a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts @@ -10,7 +10,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' -import { StructuredAgentRegistry, structuredAgentRewindSupport } from './structured-agent-registry' +import { StructuredAgentRegistry } from './structured-agent-registry' import type { StructuredAgentDefinition } from './structured-agent-definition' const LOCAL: AgentSessionExecutionLocation = { @@ -149,22 +149,26 @@ describe('StructuredAgentSessionAdapterRouter registry', () => { expect(agents.definitions()).toEqual([CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT]) }) - it('lets a session narrow a declared rewind but never widen an undeclared one', () => { + it('lets a session narrow a declared rewind but never widen an undeclared one', async () => { const narrowing = declaringAdapter({ rewindSupport: () => ({ supported: false, reason: 'history-not-paginated' }) }) const widening = declaringAdapter({ rewindSupport: () => ({ supported: true }) }) - const agents = claudeAndCodex({ codex: narrowing, claude: widening }) - const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) + const router = new StructuredAgentSessionAdapterRouter( + claudeAndCodex({ codex: narrowing, claude: widening }), + async () => {} + ) + const unsupported = { supported: false, reason: 'unsupported' } - expect(structuredAgentRewindSupport(agents, router, 'session-1', 'codex')).toEqual({ + expect(router.rewindSupport('session-1', 'codex')).toEqual({ supported: false, reason: 'history-not-paginated' }) - expect(structuredAgentRewindSupport(agents, router, 'session-1', 'claude')).toEqual({ - supported: false, - reason: 'unsupported' - }) + // Claude declares no rewind; its adapter's answer cannot claim one, at rest or live. + expect(router.rewindSupport('session-1', 'claude')).toEqual(unsupported) + await router.acquire({ identity: identity('session-1', 'claude'), fence: 1, spawnToken: 's' }) + expect(router.rewindSupport('session-1')).toEqual(unsupported) + expect(router.rewindSupport('session-2', 'grok')).toEqual(unsupported) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index b7191d50f54c..689381b4ffff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -71,13 +71,13 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) - /** The owning adapter's narrowing for this session; the host combines it with the declaration. */ + /** The owner's declared rewind, narrowed by its adapter for this session; never widened. */ rewindSupport: NonNullable = ( sessionId, agent ) => { const owner = this.capabilityOwner(sessionId, agent) - if (!owner) { + if (!owner?.definition.capabilities.rewind) { return { supported: false, reason: 'unsupported' } } return owner.adapter.rewindSupport?.(sessionId) ?? { supported: true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 5bd42f2b2eb2..e5ac31026ee8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -266,7 +266,7 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & beforeDispatch?: () => Promise }): Promise /** How this session narrows its agent's declared rewind; `agent` answers for one with no child - * running. The host reads the declaration first: this never widens it. */ + * running. The router applies the declaration first, so an adapter's answer never widens it. */ rewindSupport?(sessionId: string, agent?: string): AgentSessionRewindSupport recoverRewind?(input: { sessionId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 67cc0279294f..314f436f1d25 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -15,10 +15,7 @@ import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structu import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentDefinition } from './structured-agent-definition' -import { - structuredAgentRewindSupport, - type StructuredAgentRegistry -} from './structured-agent-registry' +import type { StructuredAgentRegistry } from './structured-agent-registry' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { structuredAgentSessionOptionModels } from './structured-agent-session-option-models' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -138,7 +135,10 @@ export async function readStructuredAgentSessionOptions( rewind: phase === 'prepared' || phase === 'provider-succeeded' ? { supported: false, reason: 'outcome-unknown' } - : structuredAgentRewindSupport(agents, adapter, sessionId, agent), + : (adapter.rewindSupport?.(sessionId, agent) ?? { + supported: false, + reason: 'unsupported' + }), conversationCommands: capabilities?.compact ? ['clear', 'compact'] : ['clear'], ...(capabilities?.threadGoal ? { threadGoal: { current: session.journal.threadGoal() } } : {}), ...(capabilities?.contextUsage diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index a435dad55ba9..7ccf8f7bbed9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -21,7 +21,6 @@ import type { StructuredAgentSessionCaller } from './structured-agent-session-ho import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' import { conversationCommandBlocked } from './structured-conversation-command-admission' import { rewindRefusal } from './structured-rewind-refusal' -import { structuredAgentRewindSupport } from './structured-agent-registry' import { persistRewindRecord, recoverStructuredRewind } from './structured-rewind-recovery' import { mergeRetainedHostLifecycleRows } from './structured-rewind-retained-host-rows' @@ -66,14 +65,9 @@ export async function rewindStructuredAgentSession( run: async (ctx) => { await attachContext.runtimeState.flushEventSink(sessionId) const record = store.getRecord(sessionId)! - const support = structuredAgentRewindSupport( - ctx.agents, - ctx.adapter, - sessionId, - ctx.agent - ) - if (!support.supported) { - return rewindRefusal(support.reason) + const support = ctx.adapter.rewindSupport?.(sessionId, ctx.agent) + if (!support?.supported) { + return rewindRefusal(support?.reason ?? 'unsupported') } if ( record.rewind?.phase === 'prepared' || From b494ed62d8e71f06ebcf2026511f4e608a7949b2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:37:13 -0700 Subject: [PATCH 148/269] test(agent-session): register the agents the merged-in tests now need The adoption replay test builds its host over this build's registered agents instead of an adapter definition method, and the stored-form test passes the stored agents the record guard now requires. --- .../structured-agent-session-adoption-replay.test.ts | 6 ++---- src/shared/agent-session-record-stored-form.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 536af042fe46..db540d3bbd73 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -8,7 +8,6 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/ import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' -import { claudeAndCodexDefinition } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import { OrcaRuntimeService } from '../../orca-runtime' import type { RpcRequest, RpcResponse } from '../core' @@ -17,7 +16,7 @@ import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import { claudeAndCodexAgents } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const SESSION = 'session-adoption-replay' const THREAD = 'thread-adoption-replay' @@ -35,7 +34,6 @@ let host: StructuredAgentSessionHost function adapter(): StructuredAgentSessionAdapter { return { supportsCreate: () => true, - definition: claudeAndCodexDefinition, acquire: vi .fn() .mockImplementation(async ({ fence, spawnToken }) => ({ @@ -190,7 +188,7 @@ describe('committed adopting create RPC replay', () => { const store = await openTestAgentSessionRecordStore(root) const sessionAdapter = adapter() host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, + agents: claudeAndCodexAgents(sessionAdapter), logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, diff --git a/src/shared/agent-session-record-stored-form.test.ts b/src/shared/agent-session-record-stored-form.test.ts index 1d075ae10539..c78f190a4288 100644 --- a/src/shared/agent-session-record-stored-form.test.ts +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -242,8 +242,8 @@ describe('what this build writes', () => { stored.providerHandleChain[0].handle.laterHandleField = 'x' stored.providerHandleChain[0].laterLinkField = 'y' stored.laterRecordField = 'z' - expect(isPersistedAgentSessionRecord(stored)).toBe(true) - if (!isPersistedAgentSessionRecord(stored)) { + expect(isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)).toBe(true) + if (!isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)) { return } const written = JSON.parse( From 7591167f833520fe089f1e1ddf44710431568230 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:39:11 -0700 Subject: [PATCH 149/269] chore(agent-session): the registry is the one lookup; drop the test-only empty capability record --- .../agent-session-wire/structured-agent-definition.ts | 4 ++-- ...ured-agent-session-adapter-router-registry.test.ts | 7 +++++-- src/shared/agent-session-capabilities.ts | 11 ----------- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts index 89614e66e6fd..8744ccfde1fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts @@ -1,8 +1,8 @@ // What the host knows about a structured agent before any session of it runs. // // Each agent's own module declares its definition, and runtime composition registers it with the -// agent's adapter. The router's registrations are the only lookup: shared host code reads a -// definition through them and never branches on the agent's name. +// agent's adapter in one StructuredAgentRegistry. That registry is the only lookup: the router and +// shared host code read a definition through it and never branch on the agent's name. import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts index 6ebb9bdcd79a..c16c035ef6d8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { NO_AGENT_SESSION_CAPABILITIES } from '../../../shared/agent-session-capabilities' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' @@ -61,7 +60,11 @@ function fakeAdapter( const PILOT: StructuredAgentDefinition = { agent: 'grok', capabilities: { - ...NO_AGENT_SESSION_CAPABILITIES, + rewind: false, + compact: false, + threadGoal: false, + contextUsage: false, + imagePrompts: false, steering: 'queue', approvalEnforcement: 'orca' }, diff --git a/src/shared/agent-session-capabilities.ts b/src/shared/agent-session-capabilities.ts index 78d63ef70d60..285fcfe2d457 100644 --- a/src/shared/agent-session-capabilities.ts +++ b/src/shared/agent-session-capabilities.ts @@ -27,14 +27,3 @@ export type AgentSessionCapabilities = { */ approvalEnforcement: 'provider' | 'orca' } - -/** The answer for an agent no definition covers: nothing beyond a plain chat, claimed by no one. */ -export const NO_AGENT_SESSION_CAPABILITIES: AgentSessionCapabilities = Object.freeze({ - rewind: false, - compact: false, - threadGoal: false, - contextUsage: false, - imagePrompts: false, - steering: 'queue', - approvalEnforcement: 'orca' -}) From 4f32dc9049e92919e3bd365f7b79f13a83675e7e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:34:05 -0700 Subject: [PATCH 150/269] fix(agent-session): keep "dismiss all" restart offers dismissed for the desktop The desktop renderer calls the restart methods as a paired runtime client without the registered-agents capability, so it was handed a Claude/Codex audience and its "dismiss all" took the scoped path: no dismissal fence and no clearing of unwritten teardown witnesses, so a late teardown write could bring a dismissed offer back. The audience is now derived against this host's registry: a client that can show every agent the host registered gets none, and dismisses exactly as before (one fence for every offer). A client that truly cannot show some agent still gets a final dismissal for what it sees: each cleared session is fenced on its own (bounded, superseded by a dismiss-all fence) and only the witnesses of agents it sees are dropped. One predicate now answers which agents a client renders, for both tab projection and restart offers; the Claude capability rule is folded in. --- ...red-agent-session-restart-audience.test.ts | 3 +- ...ed-agent-session-restart-failure-ledger.ts | 26 ++--- ...ssion-restart-interruption-test-harness.ts | 9 +- ...tured-agent-session-restart-resume-host.ts | 2 +- ...ed-agent-session-restart-witnesses.test.ts | 52 +++++++++ ...uctured-agent-session-restart-witnesses.ts | 24 +++-- .../agent-session-recovery-capsule-entries.ts | 102 +++++++++++++++++- .../agent-session-recovery-capsule.test.ts | 53 +++++++++ .../runtime/agent-session-recovery-capsule.ts | 51 ++++----- .../session-tab-agent-status-projection.ts | 20 +--- .../structured-agent-session-policy.ts | 44 +++++--- ...gent-session-restart-dismiss-fence.test.ts | 102 ++++++++++++++++++ ...tured-agent-session-restart-resume.test.ts | 19 +++- ...structured-agent-session-restart-resume.ts | 6 +- 14 files changed, 425 insertions(+), 88 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts index f468d0bd12ee..909e7583e08c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts @@ -74,6 +74,7 @@ it('keeps a failure the caller cannot show, and leaves it out of an action reply expect(await host.restartResume.dismiss(undefined, cannotShowCodex)).toBe(0) expect(await host.restartResume.listFailures()).toHaveLength(1) - expect(await host.restartResume.dismiss(undefined, showsCodex)).toBe(1) + // A dismiss-all counts pending offers, as it always has; the failure goes with it all the same. + expect(await host.restartResume.dismiss(undefined, showsCodex)).toBe(0) expect(await host.restartResume.listFailures()).toEqual([]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index 0b217d58303b..8c986463e82b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts @@ -59,11 +59,11 @@ export type StructuredAgentSessionRestartFailureLedger = { failureReason: (sessionId: string) => string } ) => Promise - /** Named sessions forget their offer or failure; unnamed, every durable record goes. With an - * audience, only records of agents it sees go, and an unnamed dismissal is no fence. */ + /** Named sessions forget their offer or failure; unnamed, every durable record goes and stays + * gone. With an audience, only records of agents it sees go, and only they are fenced. */ dismiss: ( sessionIds: readonly string[] | undefined, - beforeClearAll: () => void, + beforeClearAll: (audience?: StructuredAgentSessionRestartAudience) => void, audience?: StructuredAgentSessionRestartAudience ) => Promise } @@ -216,20 +216,20 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { settle, dismiss: (sessionIds, beforeClearAll, audience) => deps.enqueue(async () => { - if (audience) { - // Decided under the capsule lock. A record whose chat this host cannot read names no - // agent the audience was shown, so it stays. - const hidden = (marker: AgentSessionResumeMarker) => { - const record = deps.getRecord(marker.sessionId) - return record === null || !audience(record.provider) + // Decided under the capsule lock. A record whose chat this host cannot read names no + // agent the audience was shown, so it stays. + const hidden = (marker: AgentSessionResumeMarker) => { + if (!audience) { + return false } - return (await deps.capsule?.dismiss(sessionIds ?? 'all', deps.now(), hidden)) ?? 0 + const record = deps.getRecord(marker.sessionId) + return record === null || !audience(record.provider) } if (sessionIds !== undefined) { - return (await deps.capsule?.dismiss(sessionIds, deps.now())) ?? 0 + return (await deps.capsule?.dismiss(sessionIds, deps.now(), hidden)) ?? 0 } - beforeClearAll() - return (await deps.capsule?.clearAll(deps.now())) ?? 0 + beforeClearAll(audience) + return (await deps.capsule?.clearAll(deps.now(), audience ? hidden : undefined)) ?? 0 }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index cf442256bf53..5cc9f7d39c0a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -40,7 +40,8 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' +import type { StructuredAgentRegistry } from './structured-agent-registry' /** Starts the agent explicitly — the attach a client's ensure makes — for a test that needs a * running child before its next step. Nothing else starts one ahead of a send. */ @@ -61,7 +62,9 @@ export async function interruptedRestart( /** What the restarted host proves about the recorded owner; gone unless a test says otherwise. */ probeOwner: NonNullable = async () => ({ outcome: 'pid-absent' - }) + }), + /** What the relaunched host registers; restart actions scope by it. */ + agents: StructuredAgentRegistry = claudeAndCodexDeclared() ) { const previous = hostTestState() let children: AgentChildWorkView[] = [] @@ -133,7 +136,7 @@ export async function interruptedRestart( const clock = { now: NOW + 1 } const log = recordingProductionStructuredAgentSessionLogger() const host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, + agents, logger: log.logger, store, adapter: { 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 3e30b0baf92c..ac91a00f8723 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 @@ -179,7 +179,7 @@ export function createStructuredAgentSessionRestartResume( ) => { // An explicit action supersedes teardown witnesses captured by this host. The durable mutation // lane below also drains a publication already in flight before completion. - witnesses.clear() + witnesses.clear(audience) const markers = await readActionMarkers(sessionIds) await revealMarkers(markers) const requested = new Set(sessionIds ?? markers.map((marker) => marker.sessionId)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts new file mode 100644 index 000000000000..739ad2c5a33f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import { createStructuredAgentSessionRestartWitnesses } from './structured-agent-session-restart-witnesses' +import { marker } from './structured-agent-session-restart-resume-test-harness' + +// Every stopped session counts as cut off; what is under test is which witnesses a clear drops. +vi.mock('./structured-agent-session-working-at-teardown', () => ({ + structuredAgentSessionWorkingAtStop: (input: { sessionId: string; now: number }) => + marker({ sessionId: input.sessionId, recordedAt: input.now }) +})) + +const AGENTS: Record = { 'codex-chat': 'codex', 'grok-chat': 'grok' } + +function witnessed() { + const recorded: AgentSessionResumeMarker[][] = [] + const witnesses = createStructuredAgentSessionRestartWitnesses({ + sessions: new Map(), + getRecord: (sessionId) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: clear reads only the record's provider. + AGENTS[sessionId] ? ({ provider: AGENTS[sessionId] } as AgentSessionRecord) : null, + childWork: () => undefined, + capsule: { record: async (markers) => void recorded.push([...markers]) }, + teardownId: 'teardown', + now: () => 1, + enqueue: (operation) => operation() + }) + witnesses.begin('quit') + for (const sessionId of ['codex-chat', 'grok-chat', 'unreadable-chat']) { + witnesses.beforeStop(sessionId) + witnesses.stopped(sessionId) + } + const written = async () => { + await witnesses.record() + return (recorded.at(-1) ?? []).map((each) => each.sessionId) + } + return { witnesses, written } +} + +describe('restart witnesses', () => { + it('drops every unwritten witness on an unscoped clear', async () => { + const { witnesses, written } = witnessed() + witnesses.clear() + expect(await written()).toEqual([]) + }) + + it('drops only the witnesses of agents the audience sees on a scoped clear', async () => { + const { witnesses, written } = witnessed() + witnesses.clear((agent) => agent !== 'grok') + expect(await written()).toEqual(['grok-chat', 'unreadable-chat']) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts index 2beee1b5a59c..683fe79d9e87 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts @@ -24,8 +24,9 @@ export type StructuredAgentSessionRestartWitnesses = { /** The child is proven gone, so what it was doing was cut off. */ stopped: (sessionId: string) => void record: () => Promise - /** An explicit action on the offer supersedes witnesses this host has not yet written. */ - clear: () => void + /** An explicit action on the offer supersedes witnesses this host has not yet written; with an + * audience, only those of agents it sees. A session whose record is unreadable here stays. */ + clear: (audience?: (agent: string) => boolean) => void } export function createStructuredAgentSessionRestartWitnesses(deps: { @@ -40,10 +41,21 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { let trigger: AgentSessionResumeTrigger | null = null const stopping = new Map() const confirmed = new Map() - const clear = (): void => { - trigger = null - stopping.clear() - confirmed.clear() + const clear = (audience?: (agent: string) => boolean): void => { + if (!audience) { + trigger = null + stopping.clear() + confirmed.clear() + return + } + for (const witnessed of [stopping, confirmed]) { + for (const sessionId of witnessed.keys()) { + const record = deps.getRecord(sessionId) + if (record && audience(record.provider)) { + witnessed.delete(sessionId) + } + } + } } return { begin: (next) => { diff --git a/src/main/runtime/agent-session-recovery-capsule-entries.ts b/src/main/runtime/agent-session-recovery-capsule-entries.ts index a5d1d899723a..2d7bfa4b24e5 100644 --- a/src/main/runtime/agent-session-recovery-capsule-entries.ts +++ b/src/main/runtime/agent-session-recovery-capsule-entries.ts @@ -42,8 +42,12 @@ const capsuleSchema = z.object({ version: z.literal(2), entries: z.array(z.unknown()), dismissedAt: z.number().int().nonnegative().optional(), + dismissedSessions: z.unknown().optional(), failed: z.unknown().optional() }) +const sessionFencesSchema = z.record(z.string().min(1), z.number().int().nonnegative()) +/** Newest per-session fences kept; a dismiss-all fence supersedes every older one. */ +const MAX_SESSION_FENCES = 512 /** What an acted-on offer left behind when the agent did not carry on. Current only while nothing * newer happened in that chat; also dies with a dismissal, a successful retry, or a newer @@ -79,7 +83,101 @@ export type RecoveryEntry = { export type RecoveryCapsuleState = { entries: RecoveryEntry[] failed: AgentSessionResumeFailureRecord[] +} & RecoveryCapsuleFence + +/** What keeps a dismissed offer dismissed: a teardown witness recorded at or before the fence is + * dropped. `dismissedAt` fences every session (dismiss-all); `dismissedSessions` fences single + * sessions, for a dismiss-all scoped to the agents one client sees. */ +export type RecoveryCapsuleFence = { + dismissedAt?: number + dismissedSessions?: ReadonlyMap +} + +export function isFencedOut( + fence: RecoveryCapsuleFence, + marker: AgentSessionResumeMarker +): boolean { + const at = Math.max(fence.dismissedAt ?? -1, fence.dismissedSessions?.get(marker.sessionId) ?? -1) + return marker.recordedAt <= at +} + +/** `fence` with each of `sessionIds` fenced at `at`. */ +function withSessionFences( + fence: RecoveryCapsuleFence, + sessionIds: Iterable, + at: number +): RecoveryCapsuleFence { + const dismissedSessions = new Map(fence.dismissedSessions) + for (const sessionId of sessionIds) { + dismissedSessions.set(sessionId, Math.max(at, dismissedSessions.get(sessionId) ?? at)) + } + return { ...fence, dismissedSessions } +} + +/** A dismiss-all of `state`: every record but those `keep` answers true for goes, and stays gone. + * An in-flight action's later complete/rollback becomes a no-op, and a late teardown writer is + * fenced: for every session, or with `keep` for each cleared one, so a session kept for a client + * that sees it is never fenced by one that does not. */ +export function dismissAll( + state: RecoveryCapsuleState, + now: number, + keep?: (marker: AgentSessionResumeMarker) => boolean +): { + records: Pick + fence: RecoveryCapsuleFence + pendingCleared: number +} { + const { entries, failed } = normalizeState(state, now) + const kept = (record: { marker: AgentSessionResumeMarker }) => keep?.(record.marker) === true + const cleared = [...entries, ...failed].filter((record) => !kept(record)) + return { + records: { entries: entries.filter(kept), failed: failed.filter(kept) }, + fence: keep + ? withSessionFences( + state, + cleared.map((record) => record.marker.sessionId), + now + ) + : { dismissedAt: now }, + pendingCleared: entries.filter((entry) => entry.state === 'pending' && !kept(entry)).length + } +} + +/** The capsule file's contents. */ +export function storedCapsule( + { entries, failed }: Pick, + fence: RecoveryCapsuleFence +) { + return { + version: 2, + entries, + ...storedFence(fence), + ...(failed.length === 0 ? {} : { failed }) + } +} + +/** The fence as stored: a session fence the dismiss-all fence covers is dropped, and only the + * newest are kept, so the set cannot grow without bound. */ +function storedFence(fence: RecoveryCapsuleFence): { dismissedAt?: number + dismissedSessions?: Record +} { + const sessions = [...(fence.dismissedSessions ?? new Map())] + .filter(([, at]) => fence.dismissedAt === undefined || at > fence.dismissedAt) + .sort(([, left], [, right]) => right - left) + .slice(0, MAX_SESSION_FENCES) + return { + ...(fence.dismissedAt === undefined ? {} : { dismissedAt: fence.dismissedAt }), + ...(sessions.length === 0 ? {} : { dismissedSessions: Object.fromEntries(sessions) }) + } +} + +// Advisory like failures: an unreadable set reads as none rather than costing every offer. +function parseSessionFences(value: unknown): ReadonlyMap | undefined { + const parsed = sessionFencesSchema.safeParse(value) + return parsed.success && Object.keys(parsed.data).length > 0 + ? new Map(Object.entries(parsed.data)) + : undefined } function parseMarker(value: unknown): AgentSessionResumeMarker { @@ -144,10 +242,12 @@ export function parseState(raw: string): RecoveryCapsuleState { } } const capsule = capsuleSchema.parse(value) + const dismissedSessions = parseSessionFences(capsule.dismissedSessions) return { entries: capsule.entries.map(parseEntry), failed: parseFailures(capsule.failed), - ...(capsule.dismissedAt === undefined ? {} : { dismissedAt: capsule.dismissedAt }) + ...(capsule.dismissedAt === undefined ? {} : { dismissedAt: capsule.dismissedAt }), + ...(dismissedSessions ? { dismissedSessions } : {}) } } diff --git a/src/main/runtime/agent-session-recovery-capsule.test.ts b/src/main/runtime/agent-session-recovery-capsule.test.ts index bb3c93a6a989..8d617f115534 100644 --- a/src/main/runtime/agent-session-recovery-capsule.test.ts +++ b/src/main/runtime/agent-session-recovery-capsule.test.ts @@ -516,6 +516,59 @@ describe('durable restart offers', () => { expect(await capsule.list(NOW + 2)).toEqual([newer]) }) + it('fences each session a scoped dismiss-all clears, and leaves what it keeps unfenced', async () => { + const hidden = marker({ sessionId: 'hidden' }) + await capsule.record([marker(), hidden], NOW) + await capsule.beginResume([SESSION], 'operation-a', NOW) + + expect(await capsule.clearAll(NOW + 1, (kept) => kept.sessionId === 'hidden')).toBe(0) + expect(await capsule.list(NOW + 1)).toEqual([hidden]) + await capsule.rollbackResume('operation-a', NOW + 1) + // The stale teardown write of the cleared chat stays out; the kept chat still takes a newer one. + const newerHidden = marker({ sessionId: 'hidden', recordedAt: NOW + 1, teardownId: 'later' }) + await capsule.record([marker(), newerHidden], NOW + 1) + expect(await capsule.list(NOW + 1)).toEqual([newerHidden]) + + const newer = marker({ recordedAt: NOW + 2, teardownId: 'teardown-new' }) + await capsule.record([newer], NOW + 2) + expect(await capsule.list(NOW + 2)).toContainEqual(newer) + await capsule.record([marker()], NOW + 2) + expect(await capsule.list(NOW + 2)).toContainEqual(newer) + }) + + it('drops session fences a dismiss-all fence covers, and keeps the newest of the rest', async () => { + await capsule.record([marker()], NOW) + await capsule.clearAll(NOW, () => false) + expect(JSON.parse(await readFile(filePath, 'utf8'))).toMatchObject({ + dismissedSessions: { [SESSION]: NOW } + }) + + await capsule.clearAll(NOW + 1) + expect(JSON.parse(await readFile(filePath, 'utf8'))).not.toHaveProperty('dismissedSessions') + + const many = Array.from({ length: 600 }, (_, index) => + marker({ sessionId: `session-${index}`, recordedAt: NOW + 2 + index }) + ) + await capsule.record(many, NOW + 2) + for (const [index, each] of many.entries()) { + await capsule.clearAll(NOW + 2 + index, (kept) => kept.sessionId !== each.sessionId) + } + const fences = JSON.parse(await readFile(filePath, 'utf8')).dismissedSessions + expect(Object.keys(fences)).toHaveLength(512) + expect(fences).toHaveProperty('session-599') + expect(fences).not.toHaveProperty('session-0') + }) + + it('lets a scoped dismiss-all replace an unreadable file with an empty fence', async () => { + await writeFile(filePath, '{') + + expect(await capsule.clearAll(NOW, () => true)).toBe(0) + expect(JSON.parse(await readFile(filePath, 'utf8'))).toMatchObject({ + entries: [], + dismissedAt: NOW + }) + }) + it('keeps a legacy v1 file readable until a mutating operation migrates it', async () => { const legacy = JSON.stringify({ version: 1, markers: [marker()] }) await writeFile(filePath, legacy) diff --git a/src/main/runtime/agent-session-recovery-capsule.ts b/src/main/runtime/agent-session-recovery-capsule.ts index 7343ab64e8e1..ee7002558e58 100644 --- a/src/main/runtime/agent-session-recovery-capsule.ts +++ b/src/main/runtime/agent-session-recovery-capsule.ts @@ -11,10 +11,14 @@ import { } from '../durable-file-write' import { withFileTransactionLock } from '../file-transaction-lock' import { + dismissAll, + isFencedOut, MAX_FAILURE_FIELD_LENGTH, normalizeState, parseState, shouldReplaceMarker, + storedCapsule, + type RecoveryCapsuleFence, type AgentSessionResumeFailureInput, type AgentSessionResumeFailureRecord, type RecoveryCapsuleState, @@ -64,9 +68,8 @@ export class AgentSessionRecoveryCapsule { const { entries, failed } = normalizeState(state, now) const bySession = new Map(entries.map((entry) => [entry.marker.sessionId, entry])) const failedBySession = new Map(failed.map((failure) => [failure.marker.sessionId, failure])) - const dismissedAt = state.dismissedAt for (const marker of markers) { - if (dismissedAt !== undefined && marker.recordedAt <= dismissedAt) { + if (isFencedOut(state, marker)) { continue } const existing = bySession.get(marker.sessionId) @@ -92,7 +95,7 @@ export class AgentSessionRecoveryCapsule { } // Keep the fence after a newer interruption. It still admits genuinely newer markers, // while an older delayed writer remains unable to resurrect a dismissed chat later. - await this.publish({ entries: [...bySession.values()], failed }, now, dismissedAt) + await this.publish({ entries: [...bySession.values()], failed }, now, state) }) } @@ -125,7 +128,7 @@ export class AgentSessionRecoveryCapsule { next.push(reserve(failure.marker)) } } - await this.publish({ entries: next, failed }, now, state.dismissedAt) + await this.publish({ entries: next, failed }, now, state) return selected }) } @@ -150,7 +153,7 @@ export class AgentSessionRecoveryCapsule { failed: failed.filter((failure) => !completed.has(failure.marker.sessionId)) }, now, - state.dismissedAt + state ) }) } @@ -194,7 +197,7 @@ export class AgentSessionRecoveryCapsule { ] }, now, - state.dismissedAt + state ) }) } @@ -225,7 +228,7 @@ export class AgentSessionRecoveryCapsule { failed: failed.filter((failure) => !dismissed.has(failure.marker.sessionId)) }, now, - state.dismissedAt + state ) } return dismissed.size @@ -260,7 +263,7 @@ export class AgentSessionRecoveryCapsule { ) ) if (keptEntries.length !== entries.length || keptFailures.length !== failed.length) { - await this.publish({ entries: keptEntries, failed: keptFailures }, now, state.dismissedAt) + await this.publish({ entries: keptEntries, failed: keptFailures }, now, state) } }) } @@ -275,27 +278,23 @@ export class AgentSessionRecoveryCapsule { : entry ) // Normalizing on publish keeps a rolled-back retry a failure rather than a pending offer. - await this.publish({ entries: next, failed }, now, state.dismissedAt) + await this.publish({ entries: next, failed }, now, state) }) } - clearAll(now: number): Promise { + /** The explicit request to forget every record, or all but `keep`'s; see `dismissAll`. */ + clearAll(now: number, keep?: (marker: AgentSessionResumeMarker) => boolean): Promise { return withFileTransactionLock(this.filePath, async () => { - let entries: RecoveryEntry[] + let dismissal: ReturnType try { - entries = normalizeState(await this.readState(), now).entries + dismissal = dismissAll(await this.readState(), now, keep) } catch { - // Dismiss is an explicit request to forget this advisory file. Replace unreadable bytes - // with an empty, fenced capsule so a late teardown writer cannot resurrect the offer. - await this.publish({ entries: [], failed: [] }, now, now) + // Unreadable bytes hold no offer anyone sees; the dismissal must still land, fenced. + await this.publish({ entries: [], failed: [] }, now, { dismissedAt: now }) return 0 } - const pending = entries.filter((entry) => entry.state === 'pending') - // Dismiss is the explicit user request to forget every recovery record. An in-flight - // action may still finish, but its later complete/rollback becomes a no-op and cannot - // resurrect a row the user dismissed. - await this.publish({ entries: [], failed: [] }, now, now) - return pending.length + await this.publish(dismissal.records, now, dismissal.fence) + return dismissal.pendingCleared }) } @@ -321,16 +320,10 @@ export class AgentSessionRecoveryCapsule { private async publish( records: StoredRecords, now: number, - dismissedAt: number | undefined + fence: RecoveryCapsuleFence ): Promise { - const { entries, failed } = normalizeState(records, now) const { serialized } = stringifyJsonWithinByteLimit( - { - version: 2, - entries, - ...(dismissedAt === undefined ? {} : { dismissedAt }), - ...(failed.length === 0 ? {} : { failed }) - }, + storedCapsule(normalizeState(records, now), fence), MAX_CAPSULE_BYTES ) await removeStaleDurableWriteTempFiles(this.filePath, { diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts index d088a13e44f3..995c8281d870 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts @@ -1,8 +1,5 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, - CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../../shared/protocol-version' import type { @@ -11,7 +8,10 @@ import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' import type { TabGroupLayoutNode } from '../../../../shared/tab-types' -import { supportsStructuredAgentSessions } from './structured-agent-session-policy' +import { + clientRendersStructuredAgent, + supportsStructuredAgentSessions +} from './structured-agent-session-policy' type SessionTabsPayload = RuntimeMobileSessionTabsResult | RuntimeMobileSessionTabsSnapshot @@ -23,19 +23,9 @@ function clientCanRenderStructuredAgentSessionTab( tab: RuntimeMobileSessionAgentTab, clientCapabilities: readonly RuntimeCapability[] | undefined ): boolean { - if (!clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) { - return false - } - if (tab.agent === 'codex') { - return true - } // Every shipped client reads only Claude and Codex as chats; any other agent's tab would list // with an empty pane, so it waits for a client that says it renders the host's agents. - return clientCapabilities.includes( - tab.agent === 'claude' - ? CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - : STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY - ) + return clientRendersStructuredAgent(clientCapabilities, tab.agent) } function resolveMobileStructuredChatFallbackTitle( diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts index 8088d2621896..479b67289c93 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -1,8 +1,9 @@ -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import { + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + type RuntimeCapability } from '../../../../shared/protocol-version' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcContext } from '../core' @@ -23,18 +24,37 @@ export function supportsStructuredAgentSessions( ) } -/** Which agents' rows this client reads, or undefined when it reads every agent the host runs. - * Every client knows Claude and Codex; any other agent reaches only a client that renders the - * host's registered agents. */ +/** Whether a remote client renders `agent`'s chat: the one rule for every surface that withholds + * an agent's rows (tabs, restart offers). Codex needs structured support; Claude also its own + * capability; any other agent a client that renders the host's registered agents. */ +export function clientRendersStructuredAgent( + clientCapabilities: readonly RuntimeCapability[] | undefined, + agent: string +): boolean { + if (!clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) { + return false + } + if (agent === 'codex') { + return true + } + return clientCapabilities.includes( + agent === 'claude' + ? CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + : STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) +} + +/** The agents this client reads rows of, among those this host `registered`; undefined when it + * reads every one, so an action for it is exactly the unscoped one (one fence for every offer). */ export function structuredAgentsReadBy( - context: Pick + context: Pick, + registered: readonly { agent: string }[] ): ((agent: string) => boolean) | undefined { - return context.clientKind === undefined || - context.clientCapabilities?.includes( - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY - ) === true - ? undefined - : isAgentSessionHandleProvider + if (context.clientKind === undefined) { + return undefined + } + const reads = (agent: string) => clientRendersStructuredAgent(context.clientCapabilities, agent) + return registered.every(({ agent }) => reads(agent)) ? undefined : reads } /** diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts new file mode 100644 index 000000000000..8c14b946ce8b --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts @@ -0,0 +1,102 @@ +// "Dismiss all" from the desktop prompt, through the context the desktop renderer really dispatches +// with: a paired runtime client without the registered-agents capability. A dismissal must stay +// final for every offer that client was shown, and leave an agent it cannot show alone. + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { + AGENT_SESSION_RECOVERY_CAPSULE_FILE, + AgentSessionRecoveryCapsule +} from '../../agent-session-recovery-capsule' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' +import { CLAUDE_STRUCTURED_AGENT } from '../../../claude/claude-structured-agent-definition' +import { CODEX_STRUCTURED_AGENT } from '../../../codex/codex-structured-agent-definition' +import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' +import { StructuredAgentRegistry } from '../../../native-chat/agent-session-wire/structured-agent-registry' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { interruptedRestart } from '../../../native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness' +import { HOST_TEST_NOW as NOW } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { call, clearStructuredHostStub } from './structured-agent-session-rpc.test-fixture' + +afterEach(() => { + clearStructuredHostStub() + vi.restoreAllMocks() +}) + +// Exactly what `runtime:call` in src/main/ipc/runtime.ts dispatches the local renderer with. +const DESKTOP = { + clientKind: 'runtime' as const, + clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] +} + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a registry reads only the methods a declaration needs; these declare none. +const NO_METHODS = {} as StructuredAgentSessionAdapter +const declaring = (definition: typeof CLAUDE_STRUCTURED_AGENT) => ({ + definition: { + ...definition, + capabilities: { ...definition.capabilities, compact: false, threadGoal: false, rewind: false } + }, + adapter: NO_METHODS +}) + +/** This build's two agents plus one the desktop does not render yet. */ +function withPilot(): StructuredAgentRegistry { + return new StructuredAgentRegistry([ + declaring(CLAUDE_STRUCTURED_AGENT), + declaring(CODEX_STRUCTURED_AGENT), + declaring({ ...CODEX_STRUCTURED_AGENT, agent: 'grok', accountHomeVariable: 'GROK_HOME' }) + ]) +} + +async function storedFence(root: string) { + const { dismissedAt, dismissedSessions } = JSON.parse( + await readFile(join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), 'utf8') + ) + return { + ...(dismissedAt ? { dismissedAt } : {}), + ...(dismissedSessions ? { dismissedSessions } : {}) + } +} + +async function dismissAllFromDesktop() { + return call('agentSession.restartResumableDismiss', {}, DESKTOP) +} + +it('fences a dismissed offer against a late teardown write when the desktop sees every agent', async () => { + const { host, root, marker } = await interruptedRestart() + setStructuredAgentSessionHost(host) + const capsule = new AgentSessionRecoveryCapsule(root) + + expect(await dismissAllFromDesktop()).toMatchObject({ ok: true, result: { dismissed: 1 } }) + // A teardown writer that captured this chat before the dismissal publishes late. + await capsule.record([marker!], NOW) + + expect(await capsule.list(NOW)).toEqual([]) + // The unscoped dismissal every caller took before agents beyond Claude and Codex: one fence for all. + expect(await storedFence(root)).toEqual({ dismissedAt: NOW + 1 }) + expect(await call('agentSession.restartResumable', {}, DESKTOP)).toMatchObject({ + ok: true, + result: { sessions: [] } + }) +}) + +it('keeps a scoped dismiss-all final for what the desktop was shown when the host runs an agent it cannot show', async () => { + const { host, root, marker } = await interruptedRestart( + undefined, + undefined, + undefined, + withPilot() + ) + setStructuredAgentSessionHost(host) + const capsule = new AgentSessionRecoveryCapsule(root) + // An offer whose chat this launch cannot read names no agent the desktop was shown. + const unreadable = { ...marker!, sessionId: 'unreadable-session' } + await capsule.record([unreadable], NOW) + + expect(await dismissAllFromDesktop()).toMatchObject({ ok: true, result: { dismissed: 1 } }) + await capsule.record([marker!], NOW) + + expect(await capsule.list(NOW)).toEqual([unreadable]) + expect(await storedFence(root)).toEqual({ dismissedSessions: { [marker!.sessionId]: NOW + 1 } }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts index 88e32f076751..8101473f894b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts @@ -9,6 +9,7 @@ import { type StructuredAgentSessionRestartAudience } from '../../../native-chat/agent-session-wire/structured-agent-session-restart-resume-set' import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' import { call, clearStructuredHostStub, @@ -21,11 +22,16 @@ afterEach(clearStructuredHostStub) const CLAUDE_OFFER = { sessionId: 'claude-session', agent: 'claude' } const GROK_OFFER = { sessionId: 'grok-session', agent: 'grok' } -const OLD_CLIENT = STRUCTURED_CLIENT -const NEW_CLIENT = { +// What the desktop renderer really dispatches with: every client that calls these methods +// advertises Claude support. +const OLD_CLIENT = { ...STRUCTURED_CLIENT, + clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] +} +const NEW_CLIENT = { + ...OLD_CLIENT, clientCapabilities: [ - ...STRUCTURED_CLIENT.clientCapabilities, + ...OLD_CLIENT.clientCapabilities, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY ] } @@ -63,7 +69,12 @@ function installRestartHost() { }) ) } - setStructuredAgentSessionHost(Object.assign(hostStub(), { restartResume })) + setStructuredAgentSessionHost( + Object.assign(hostStub(), { + restartResume, + agentDefinitions: () => [{ agent: 'claude' }, { agent: 'codex' }, { agent: 'grok' }] + }) + ) return { restartResume, offers: () => offers } } 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 0155ee04e53d..bb68f2b0dcad 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 @@ -21,7 +21,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ handler: async (_params, ctx) => { await ensureStructuredHostInstalled(ctx) const host = requireStructuredHost(ctx) - const audience = structuredAgentsReadBy(ctx) + const audience = structuredAgentsReadBy(ctx, host.agentDefinitions()) return { sessions: await host.restartResume.list(audience), // Acted-on offers whose agent did not carry on. Optional on the wire; older clients ignore it. @@ -38,7 +38,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ await ensureStructuredHostInstalled(ctx) const host = requireStructuredHost(ctx) // Offers this client was never shown stay for a client that can show them. - const audience = structuredAgentsReadBy(ctx) + const audience = structuredAgentsReadBy(ctx, host.agentDefinitions()) const dismissed = await host.restartResume.dismiss(params.sessionIds, audience) if (params.sessionIds === undefined) { // The dismissal removed every pending and in-flight record this client sees, so a second @@ -64,7 +64,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ return host.restartResume.continueAfterRestart( params.sessionIds, structuredCallerFor(ctx).callerKey, - structuredAgentsReadBy(ctx) + structuredAgentsReadBy(ctx, host.agentDefinitions()) ) } }), From 212af519944a0ed00fda4d1e9bf1344ac0252ed0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:40:40 -0700 Subject: [PATCH 151/269] fix(agent-session): a changed agent definition never hides that agent's chats A record was readable only if every handle used the transport its agent's definition declares today, and its account variable was declared by some registered agent. A later build that drives the same agent over another protocol, or under a renamed variable, would have set every existing chat of that agent aside: no tab, no history, no error. Readability now asks only that the record agree with itself: its agent is registered, every handle shares one namespace owned by that agent, and the account variable is a well-formed name. Whether this build can drive it (the chain's transport is the one the agent speaks, and the account variable is that agent's own, since it becomes the child's environment) is decided in performAttach, the one admission every start of every agent passes through, and refused there as hostUnsupported. A record pinning another agent's variable is refused the same way rather than passing because some agent declares it. --- .../codex-structured-launch-resolution.ts | 2 +- ...-agent-session-acquisition-options.test.ts | 11 + ...ent-session-adapter-router-test-support.ts | 32 ++- ...tured-agent-session-adopted-import.test.ts | 1 + .../structured-agent-session-attach-flow.ts | 17 +- ...ured-agent-session-attach-orchestration.ts | 1 + ...ructured-agent-session-drivability.test.ts | 231 ++++++++++++++++++ ...ent-session-pre-spawn-first-answer.test.ts | 2 + ...nt-session-processless-reservation.test.ts | 7 + ...ructured-agent-session-provider-support.ts | 20 ++ src/shared/agent-session-account-home.test.ts | 35 +-- src/shared/agent-session-record.ts | 36 +-- src/shared/agent-session-stored-agent.test.ts | 21 +- src/shared/agent-session-stored-agent.ts | 16 -- 14 files changed, 368 insertions(+), 64 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 9802b18286df..18db5def05de 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -95,7 +95,7 @@ export function createCodexStructuredLaunchResolver( // concern, and the permission posture they used to smuggle in is derived per acquisition. const permissionPolicy = deps.resolvePermissionPolicy?.() const head = agentSessionProviderHandleChainHead(record.providerHandleChain) - // A Codex record's chain holds only Codex handles; the record store refuses anything else. + // A Codex record's chain holds only Codex handles; the attach admission refuses anything else. const resumeThreadId = head?.handle.nativeId ?? null // The same saved options every turn sends, so the thread and its turns name one model. const model = record.options?.model diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index 197585c42b59..64e42f3ebc9f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -22,6 +22,7 @@ import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent- import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const NOW = 1_800_000_000_000 const SESSION = 'legacy-session' @@ -153,6 +154,7 @@ describe('structured session acquisition options', () => { let firstJournal: AgentSessionJournal | undefined const first = await performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store: initialStore, adapter: withHistory('created'), @@ -191,6 +193,7 @@ describe('structured session acquisition options', () => { const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const second = await performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: withHistory('resumed'), @@ -223,6 +226,7 @@ describe('structured session acquisition options', () => { const recordPhase = vi.fn() const created = await performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, @@ -253,6 +257,7 @@ describe('structured session acquisition options', () => { const sessionAdapter = adapter({ origin: 'created' }) const attempt = async (options: Readonly>, spawnToken: string) => performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, @@ -284,6 +289,7 @@ describe('structured session acquisition options', () => { const store = await openTestAgentSessionRecordStore(root) const created = await performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter({ origin: 'created' }), @@ -315,6 +321,7 @@ describe('structured session acquisition options', () => { }) const releasedFence = resumedStore.getRecord(SESSION)?.lease.runtimeFence ?? 0 const resumed = await performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store: resumedStore, adapter: adapter({ @@ -358,6 +365,7 @@ describe('structured session acquisition options', () => { }) const created = await performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, @@ -395,6 +403,7 @@ describe('structured session acquisition options', () => { await expect( performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: failingAdapter, @@ -489,6 +498,7 @@ describe('structured session acquisition options', () => { fence: number | null ) => performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store: target, adapter: failingAdapter, @@ -591,6 +601,7 @@ describe('the tab a create reserves', () => { function attachWith(store: AgentSessionRecordStore, surfaceTabId?: string) { return performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter({ origin: 'created' }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts index 4d1dffb8a391..6c5869c538c3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts @@ -5,12 +5,38 @@ import { StructuredAgentSessionAdapterRouter } from './structured-agent-session- import type { StructuredAgentDefinition } from './structured-agent-definition' import { StructuredAgentRegistry } from './structured-agent-registry' -/** A runtime that drives no agent: what a host over a bare adapter double declares. */ -export const NO_STRUCTURED_AGENTS = new StructuredAgentRegistry([]) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only the optional methods a declaration needs; this double has none, so its agents declare none. const NO_METHODS = {} as StructuredAgentSessionAdapter +/** This build's agent as a host over a bare adapter double registers it: who it is (so its chats + * start), and nothing it can do. */ +function declaringNothing(definition: StructuredAgentDefinition): StructuredAgentDefinition { + return { + ...definition, + capabilities: { + rewind: false, + compact: false, + threadGoal: false, + contextUsage: false, + imagePrompts: false, + steering: definition.capabilities.steering, + approvalEnforcement: definition.capabilities.approvalEnforcement + }, + restingOptions: { + acceptsKey: () => false, + fallbackModels: () => null, + effortDefaultsToModel: false + } + } +} + +/** Claude and Codex declaring no capability and no resting options: what a host over a bare + * adapter double declares. */ +export const NO_STRUCTURED_AGENTS = new StructuredAgentRegistry([ + { definition: declaringNothing(CLAUDE_STRUCTURED_AGENT), adapter: NO_METHODS }, + { definition: declaringNothing(CODEX_STRUCTURED_AGENT), adapter: NO_METHODS } +]) + /** This build's two agents over doubles. Each declares only what its double implements, so a * double need not carry every method its real adapter has. */ export function claudeAndCodexAgents( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 621e19d6f949..93bfc64bae2b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -125,6 +125,7 @@ async function attach( ) { store ??= await openTestAgentSessionRecordStore(root!) return performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index d1b6cd280875..f03e2c1b655e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -29,7 +29,11 @@ import { type AttachedJournal } from './structured-agent-session-attach' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { adapterSupportsCreateIfDeclared } from './structured-agent-session-provider-support' +import { + adapterSupportsCreateIfDeclared, + agentDrivesSession +} from './structured-agent-session-provider-support' +import type { StructuredAgentRegistry } from './structured-agent-registry' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import { readAgentSessionHydrationPage } from './agent-session-history-page' @@ -49,6 +53,8 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo export type AttachFlowInput = { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter + /** What decides whether this build may start a record's agent at all (`agentDrivesSession`). */ + agents: Pick logger: StructuredAgentSessionLogger authority: AgentSessionAttachAuthority callerKey: string @@ -97,8 +103,13 @@ export async function performAttach( if (!admitted.ok) { return admitted } + // Every start of every agent passes here, so this is where a record this build cannot drive (its + // transport or account variable is not its agent's) is refused; reading it never is. + const supported = (record: AgentSessionRecord | null) => + adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent) && + (record === null || agentDrivesSession(input.agents, record)) // Ensure/recovery bypass create-intent, so recheck before reserving or spawning. - if (!adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent)) { + if (!supported(store.getRecord(sessionId))) { return unsupported() } @@ -135,7 +146,7 @@ export async function performAttach( // every reservation at its effect boundary so it cannot bypass the support // gate, and release a pending reservation that support drift invalidated. reservedRecord = record - if (!adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent)) { + if (!supported(record)) { if ( record.lease.claimStatus === 'reserved' && record.lease.handoffStage === 'new-owner-proving' && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index d8f89065de4f..85dabccda7fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -135,6 +135,7 @@ async function runAttach( const attached = await performAttach({ store: context.deps.store, adapter: context.deps.adapter, + agents: context.deps.agents, logger: context.deps.logger, eventSink: attemptSink.sink, // The superseded child's writes settle into its own journal before a new child starts. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts new file mode 100644 index 000000000000..db3d812de992 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts @@ -0,0 +1,231 @@ +// A record this build cannot drive (its agent's definition now names another transport or account +// variable, or the record pins another agent's variable) stays readable; only starting its agent is +// refused, at the one launch admission every agent passes through. + +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 type { AgentSessionAccountHome } from '../../../shared/agent-session-record' +import { agentSessionStoredAgents } from '../../../shared/agent-session-stored-agent' +import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../../shared/agent-session-stored-agent.test-fixture' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' +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 type { StructuredAgentDefinition } from './structured-agent-definition' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { StructuredAgentRegistry } from './structured-agent-registry' + +const NOW = 1_800_000_000_000 +const SESSION = 'grok-session' +const GROK_HOME: AgentSessionAccountHome = { variable: 'GROK_HOME', path: '/home/dev/.grok' } +let root: string | null = null + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = null +}) + +const GROK: StructuredAgentDefinition = { + ...CODEX_STRUCTURED_AGENT, + agent: 'grok', + handleTransport: 'acp', + accountHomeVariable: 'GROK_HOME', + capabilities: { + ...CODEX_STRUCTURED_AGENT.capabilities, + rewind: false, + compact: false, + threadGoal: false + }, + restingOptions: { + acceptsKey: () => false, + fallbackModels: () => null, + effortDefaultsToModel: false + } +} +const STORAGE = agentSessionStoredAgents([...CLAUDE_AND_CODEX_STORED_AGENTS.values(), GROK]) + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only the methods a declaration needs; this one declares none of them. +const NO_METHODS = {} as StructuredAgentSessionAdapter +const grokRegistered = (definition: StructuredAgentDefinition = GROK) => + new StructuredAgentRegistry([{ definition, adapter: NO_METHODS }]) + +function params( + operation: string, + expectedRuntimeFence: number | null, + accountHome = GROK_HOME +): AgentSessionAttachParams { + const attach: AgentSessionAttachParams = { + envelope: { + sessionId: SESSION, + clientOperationId: `${NOW}-${operation.padStart(32, '0')}`, + expectedRuntimeFence, + payloadFingerprint: '' + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'grok', + agent: 'grok', + accountHome, + runtimeKind: 'native' + } + return { + ...attach, + envelope: { + ...attach.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(attach) + }) + } + } +} + +function grokAdapter(): StructuredAgentSessionAdapter { + return { + acquire: vi + .fn() + .mockImplementation(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, + link: { + linkId: `grok-link-${fence}`, + handle: { transport: 'acp', agent: 'grok', nativeId: 'grok-native-1' }, + origin: fence === 1 ? 'created' : 'resumed', + mintedAtFence: fence, + observedAt: NOW + } + })), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } +} + +async function attach(input: { + agents: StructuredAgentRegistry + adapter: StructuredAgentSessionAdapter + params: AgentSessionAttachParams + spawnToken: string + store?: AgentSessionRecordStore +}) { + const store = input.store ?? (await openTestAgentSessionRecordStore(root!, { agents: STORAGE })) + const result = await performAttach({ + agents: input.agents, + logger: createStructuredAgentSessionLogger(), + store, + adapter: input.adapter, + openConversation: async (record) => { + const journal = await openTestAttachConversation(openTestJournalHostDatabase(root!))(record) + return journal + }, + authority: { + spawnToken: input.spawnToken, + claimKeyId: 'key-1', + handoffOperationId: input.params.envelope.clientOperationId, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: input.params, + now: () => NOW, + onAttached: async (attached) => { + await attached.journal.close() + } + }) + return { result, store } +} + +/** A Grok chat created while Grok spoke ACP and pinned GROK_HOME, its child since gone, read back + * by a build that registers Grok as `relaunched` declares it. */ +async function createdGrokChat(relaunched: StructuredAgentDefinition = GROK) { + root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) + const created = await attach({ + agents: grokRegistered(), + adapter: grokAdapter(), + params: params('1', null), + spawnToken: 'spawn-a' + }) + expect(created.result).toMatchObject({ ok: true }) + const store = await openTestAgentSessionRecordStore(root, { + agents: agentSessionStoredAgents([...CLAUDE_AND_CODEX_STORED_AGENTS.values(), relaunched]) + }) + await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW + 1 }) + return { store, fence: store.getRecord(SESSION)!.lease.runtimeFence } +} + +const UNSUPPORTED = { + ok: false, + refusal: { code: 'structured_agent_session_unsupported', details: { reason: 'hostUnsupported' } } +} + +it.each([ + ['its transport', { ...GROK, handleTransport: 'grok-native' }], + ['its account variable', { ...GROK, accountHomeVariable: 'GROK_CONFIG_DIR' }] +])( + 'keeps a chat readable after its agent changes %s, and refuses to start it', + async (_change, changed) => { + const { store, fence } = await createdGrokChat(changed) + const adapter = grokAdapter() + + const { result } = await attach({ + agents: grokRegistered(changed), + adapter, + params: params('2', fence), + spawnToken: 'spawn-b', + store + }) + + expect(result).toMatchObject(UNSUPPORTED) + expect(adapter.acquire).not.toHaveBeenCalled() + // Still listed and readable: its tab publishes and its history opens. + expect(store.getRecord(SESSION)).toMatchObject({ provider: 'grok', accountHome: GROK_HOME }) + expect(store.getRecord(SESSION)?.providerHandleChain[0]?.handle.transport).toBe('acp') + } +) + +it("refuses to start a chat that pins another agent's account variable", async () => { + root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) + const adapter = grokAdapter() + + const { result } = await attach({ + agents: grokRegistered(), + adapter, + params: params('1', null, { variable: 'CODEX_HOME', path: '/home/dev/.codex' }), + spawnToken: 'spawn-a' + }) + + expect(result).toMatchObject(UNSUPPORTED) + expect(adapter.acquire).not.toHaveBeenCalled() +}) + +it('starts the same chat again once its agent is declared as it was', async () => { + const { store, fence } = await createdGrokChat() + const adapter = grokAdapter() + + const { result } = await attach({ + agents: grokRegistered(), + adapter, + params: params('2', fence), + spawnToken: 'spawn-b', + store + }) + + expect(result).toMatchObject({ ok: true }) + expect(adapter.acquire).toHaveBeenCalledOnce() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index 1e171e64199e..b69e90d3e777 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -18,6 +18,7 @@ import { openTestAttachConversation } from './structured-agent-session-attach-te import { performAttach } from './structured-agent-session-attach-flow' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' 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 = 'session-alpha' @@ -81,6 +82,7 @@ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { setOption: unused } const input = { + agents: NO_STRUCTURED_AGENTS, store, adapter, logger: createStructuredAgentSessionLogger(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index 1c55baa57b0f..9efc08c0409f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -17,6 +17,7 @@ import { performAttach } from './structured-agent-session-attach-flow' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const NOW = 1_800_000_000_000 const SESSION = 'session-alpha' @@ -84,6 +85,7 @@ describe('processless structured session reservation', () => { await expect( performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, @@ -130,6 +132,7 @@ describe('processless structured session reservation', () => { })) } as unknown as StructuredAgentSessionAdapter const input = { + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, @@ -168,6 +171,7 @@ describe('processless structured session reservation', () => { const acquire = vi.fn() const adapter = { supportsCreate, acquire } as unknown as StructuredAgentSessionAdapter const input = { + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, @@ -221,6 +225,7 @@ describe('processless structured session reservation', () => { await expect( performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, @@ -288,6 +293,7 @@ describe('processless structured session reservation', () => { releaseAcquisition: vi.fn(async () => true) } as unknown as StructuredAgentSessionAdapter const input = { + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, @@ -316,6 +322,7 @@ describe('processless structured session reservation', () => { await expect( performAttach({ + agents: NO_STRUCTURED_AGENTS, ...input, authority: { ...input.authority, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts index ce07eeb65a72..8c5237994c83 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts @@ -3,6 +3,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentRegistry } from './structured-agent-registry' export function adapterSupportsCreate( adapter: StructuredAgentSessionAdapter, @@ -39,3 +40,22 @@ export function adapterSupportsRecord( // A record stays readable unless the adapter explicitly rejects its location. return adapter.supportsLocation?.(record.location) ?? true } + +/** Whether this build can start the agent of `session`: the agent is registered here, its account + * variable is that agent's own (it becomes the child's environment), and every handle it holds is + * in the transport the agent's adapter speaks. A record failing this stays readable (tab, history); + * only starting its agent is refused, by the one launch admission every agent passes through. */ +export function agentDrivesSession( + agents: Pick, + session: Pick +): boolean { + const definition = agents.definition(session.provider) + return ( + definition !== null && + session.accountHome.variable === definition.accountHomeVariable && + session.providerHandleChain.every( + ({ handle }) => + handle.transport === definition.handleTransport && handle.agent === definition.agent + ) + ) +} diff --git a/src/shared/agent-session-account-home.test.ts b/src/shared/agent-session-account-home.test.ts index 86e6e3bc1147..0c5c63f34f9b 100644 --- a/src/shared/agent-session-account-home.test.ts +++ b/src/shared/agent-session-account-home.test.ts @@ -3,10 +3,7 @@ import { agentSessionAccountHome } from './agent-session-account-home' import { isPersistedAgentSessionRecord } from './agent-session-record' import { agentSessionRecordFixture } from './agent-session-record.test-fixture' import { encodeAgentSessionRecord } from './agent-session-record-stored-form' -import { - agentSessionStoredAgents, - isDeclaredAccountHomeVariable -} from './agent-session-stored-agent' +import { agentSessionStoredAgents } from './agent-session-stored-agent' import { CLAUDE_AND_CODEX_STORED_AGENTS } from './agent-session-stored-agent.test-fixture' describe('agent session account home', () => { @@ -22,30 +19,20 @@ describe('agent session account home', () => { ).toBe('{"variable":"CODEX_HOME","path":"/home/dev/.codex"}') }) - it('admits only a variable a registered agent declares', () => { - expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, 'CLAUDE_CONFIG_DIR')).toBe( - true - ) - expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, 'CODEX_HOME')).toBe(true) - expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, 'PATH')).toBe(false) - expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, undefined)).toBe(false) - const withGrok = agentSessionStoredAgents([ - ...CLAUDE_AND_CODEX_STORED_AGENTS.values(), - { agent: 'grok', handleTransport: 'acp', accountHomeVariable: 'GROK_HOME' } - ]) - expect(isDeclaredAccountHomeVariable(withGrok, 'GROK_HOME')).toBe(true) - expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, 'GROK_HOME')).toBe(false) - }) - - it('refuses a stored record whose account home names an undeclared variable', () => { + // Whether the variable is the record's own agent's is decided when its agent would start, since + // it becomes the child's environment (structured-agent-session-drivability.test.ts). + it('reads any well-formed variable, and sets aside one that is not a variable name', () => { const record = encodeAgentSessionRecord(agentSessionRecordFixture()) - expect(isPersistedAgentSessionRecord(record, CLAUDE_AND_CODEX_STORED_AGENTS)).toBe(true) - expect( + const withVariable = (variable: string) => isPersistedAgentSessionRecord( - { ...record, accountHome: { variable: 'LD_PRELOAD', path: '/tmp/x' } }, + { ...record, accountHome: { variable, path: '/tmp/x' } }, CLAUDE_AND_CODEX_STORED_AGENTS ) - ).toBe(false) + expect(withVariable('CODEX_HOME')).toBe(true) + expect(withVariable('GROK_HOME')).toBe(true) + for (const malformed of ['', 'A B', '1HOME', 'HOME=x', 'X'.repeat(129)]) { + expect(withVariable(malformed)).toBe(false) + } }) it('refuses registering one agent twice', () => { diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index acf6ddc308e3..590658b9635e 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -26,10 +26,7 @@ import { } from './agent-session-provider-handle' import { isAgentSessionProviderHandleInNamespace } from './agent-session-provider-handle-encoding' import type { AgentSessionAccountHome } from './agent-session-account-home' -import { - isDeclaredAccountHomeVariable, - type AgentSessionStoredAgents -} from './agent-session-stored-agent' +import type { AgentSessionStoredAgents } from './agent-session-stored-agent' export type { AgentSessionAccountHome } from './agent-session-account-home' @@ -227,16 +224,18 @@ export function isAgentSessionProcessIdentity( ) } -function isAgentSessionAccountHome( - value: unknown, - agents: AgentSessionStoredAgents -): value is AgentSessionAccountHome { +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 + * (`agentDrivesRecord`), 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 ( - isDeclaredAccountHomeVariable(agents, home.variable) && + typeof home.variable === 'string' && + ENVIRONMENT_VARIABLE_NAME.test(home.variable) && isBoundedString(home.path, MAX_PATH_LENGTH) ) } @@ -339,7 +338,9 @@ function isPersistedAgentSessionLease(value: unknown): value is PersistedAgentSe /** The on-disk shape, which still admits the removed terminal handoff's lease values and stores * handles in their typed form. Decode through `decodePersistedAgentSessionRecord` before use. - * `agents` are the host's registered agents: a record of any other agent is not readable here. */ + * `agents` are the host's registered agents: a record of any other agent is not readable here. A + * registered agent's record is readable when it agrees with itself; whether this build can drive + * its transport and account variable is decided only when its agent would start. */ export function isPersistedAgentSessionRecord( value: unknown, agents: AgentSessionStoredAgents @@ -348,13 +349,13 @@ export function isPersistedAgentSessionRecord( return false } const record = value as Partial - const agent = typeof record.provider === 'string' ? agents.get(record.provider) : undefined const fieldsValid = record.schemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION && isAgentSessionId(record.sessionId) && isAgentSessionExecutionLocation(record.location) && - agent !== undefined && - isAgentSessionAccountHome(record.accountHome, agents) && + typeof record.provider === 'string' && + agents.has(record.provider) && + isAgentSessionAccountHome(record.accountHome) && (record.options === undefined || isAgentSessionOptions(record.options)) && (record.rewind === undefined || isAgentSessionRewindRecord(record.rewind)) && (record.conversationCommand === undefined || @@ -374,10 +375,15 @@ export function isPersistedAgentSessionRecord( // The row holds stored handles; validate the chain they decode to. const chain = decodePersistedAgentSessionProviderHandleChain(validated.providerHandleChain) const head = chain?.at(-1) - const namespace = { transport: agent.handleTransport, agent: agent.agent } + // One namespace, owned by the record's own agent; which transport is the chain's own fact. + const transport = chain?.[0]?.handle.transport + const namespace = transport === undefined ? null : { transport, agent: validated.provider } return ( chain !== null && - chain.every((link) => isAgentSessionProviderHandleInNamespace(link.handle, namespace)) && + chain.every( + (link) => + namespace !== null && isAgentSessionProviderHandleInNamespace(link.handle, namespace) + ) && (validated.lease.claimStatus !== 'live' || (validated.lease.ownerProcess !== null && head?.linkId === validated.lease.provenHandleLinkId && diff --git a/src/shared/agent-session-stored-agent.test.ts b/src/shared/agent-session-stored-agent.test.ts index 5b49337673c0..77fa3f84c863 100644 --- a/src/shared/agent-session-stored-agent.test.ts +++ b/src/shared/agent-session-stored-agent.test.ts @@ -43,10 +43,27 @@ describe('records of registered agents', () => { ).toBe(false) }) - it("refuses handles outside the agent's declared transport", () => { + // Whether this build can drive the chain's transport is decided when its agent would start + // (structured-agent-session-drivability.test.ts), so a definition change never hides a chat. + it("reads a record whose handles are in a transport other than the agent's current one", () => { expect( isPersistedAgentSessionRecord(encodeAgentSessionRecord(grokRecord('other')), WITH_GROK) - ).toBe(false) + ).toBe(true) + }) + + it('sets aside a record whose handles name another agent, or mix transports', () => { + const record = grokRecord() + const [link] = record.providerHandleChain + const foreign = { ...link!, handle: { ...link!.handle, agent: 'codex' } } + const mixed = { ...link!, linkId: 'second', handle: { ...link!.handle, transport: 'other' } } + for (const chain of [[foreign], [link!, mixed]]) { + expect( + isPersistedAgentSessionRecord( + encodeAgentSessionRecord({ ...record, providerHandleChain: chain }), + WITH_GROK + ) + ).toBe(false) + } }) it('keeps reading Claude and Codex records exactly as before', () => { diff --git a/src/shared/agent-session-stored-agent.ts b/src/shared/agent-session-stored-agent.ts index b013e2afacb1..79c90f245e79 100644 --- a/src/shared/agent-session-stored-agent.ts +++ b/src/shared/agent-session-stored-agent.ts @@ -32,19 +32,3 @@ export function agentSessionStoredAgents( } return byAgent } - -/** Admits only a variable a registered agent declares: a record's variable becomes a child's environment. */ -export function isDeclaredAccountHomeVariable( - agents: AgentSessionStoredAgents, - value: unknown -): value is string { - if (typeof value !== 'string') { - return false - } - for (const agent of agents.values()) { - if (agent.accountHomeVariable === value) { - return true - } - } - return false -} From e23946fe3a5a5e5ce73bdb427e93253ac0361b4b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:44:29 -0700 Subject: [PATCH 152/269] refactor(agent-session): each agent's registration says where it runs and which account it pins createSupport, create and the model catalog's account read still chose a location rule and an account resolver by name (Claude, Codex, else none), so a new agent would have needed a third branch beside the registration list that already decides storage, routing and publication. Each runtime registration now carries supportsLocation(location) and resolveAccountHomePath({ launchEnv, location, purpose, workspacePath }), and Claude's and Codex's rules move into their entries unchanged: a read still syncs no home and starts no bridge, a Codex launch still trusts the workspace first, and WSL locations resolve as before. The list exists before the host is built, so none of these reads installs the host, and an agent the list does not hold is answered no without opening the journal. --- .../structured-agent-session-host.ts | 4 +- ...structured-agent-session-create-support.ts | 89 +++++++----------- ...structured-agent-registration-seam.test.ts | 87 ++++++++++++++++++ .../structured-agent-runtime-registrations.ts | 90 ++++++++++++++++++- 4 files changed, 207 insertions(+), 63 deletions(-) create mode 100644 src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 1ccdb2188d2b..48a4e0d2bbc1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -216,9 +216,7 @@ export class StructuredAgentSessionHost { supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) - /** What this runtime registered: one agent's definition (null when unregistered), or all of them. */ - agentDefinition = (agent: string): StructuredAgentDefinition | null => - this.deps.agents.definition(agent) + /** Every agent this runtime registered: what `agentSession.agents` publishes. */ agentDefinitions = (): readonly StructuredAgentDefinition[] => this.deps.agents.definitions() private readonly tabs = sessionTabs.createStructuredAgentSessionTabSurface( diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index d3bf0e191343..e51f2c26ae22 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -1,8 +1,6 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { OrcaRuntimeWithGetWorktreePs } from './orca-runtime-get-worktree-ps' -import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' -import { supportsClaudeStructuredLocation } from '../claude/claude-structured-location-support' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { resolveStructuredAgentSessionCreateSupport } from '../native-chat/structured-agent-session-create-support' import { @@ -13,17 +11,13 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' -import { - resolveStructuredClaudeAccountHomePath, - resolveStructuredCodexAccountHomePath -} from './structured-agent-account-home' +import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations' import { resolveStructuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' import { ensureStructuredAgentSessionHostUnlessRefused } from './structured-agent-session-host-refusal' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { parseWslUncPath } from '../../shared/wsl-paths' import { parseWorkspaceKey } from '../../shared/workspace-scope' -import { applyStructuredCodexWorkspaceTrust } from '../agent-workspace-trust-spawn' import { agentSessionAccountHome, type AgentSessionAccountHome @@ -48,65 +42,48 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR }) } - /** Claude and Codex answer from their own location rules without installing the host, as they - * always have; any other agent is the answer of the adapter the host registered for it. */ + /** The agent's own location rule, from its registration: answered without installing the host, + * and false for an agent this runtime does not register. */ protected async structuredAgentSupportsLocation(agent: StructuredAgentId, location) { - if (agent === 'claude') { - return supportsClaudeStructuredLocation(location) - } - if (agent === 'codex') { - return supportsCodexStructuredLocation(location) - } - await ensureStructuredAgentSessionHostUnlessRefused(() => - this.ensureStructuredAgentSessionHost() - ) - return getStructuredAgentSessionHost()?.supportsCreate(location, agent) ?? false + return structuredAgentRuntimeRegistration(agent)?.supportsLocation(location) ?? false } - /** Where a launch of `agent` finds its account, resolved on this host; null for an agent with no - * resolver here, whose create is refused. Claude and Codex are the agents with one today. */ + /** Where a launch of `agent` finds its account, resolved on this host by the agent's own + * registration; null for an agent this runtime does not register, whose create is refused. */ protected structuredAgentAccountHomePathResolver( agent: StructuredAgentId, worktree: string, purpose: 'launch' | 'read' ) { - if (agent === 'claude') { - return async ({ launchEnv, location }) => - resolveStructuredClaudeAccountHomePath({ - launchEnv, - wslDistro: location?.wslDistro ?? null, - getClaudeConfigDirectory: (target) => this.accounts.getClaudeConfigDirectory(target) - }) + const registration = structuredAgentRuntimeRegistration(agent) + if (!registration) { + return null } - if (agent === 'codex') { - if (purpose === 'read') { - // Read-only resolver, never launch prep: a picker mount or discovery read - // must not sync homes, start bridges, or clear an account selection. - return async ({ launchEnv }) => - resolveStructuredCodexAccountHomePath({ - launchEnv, - resolveLaunchHome: this.resolveCodexStructuredLaunchHomeFn - }) - } - return async ({ launchEnv }) => { - await applyStructuredCodexWorkspaceTrust({ - workspacePath: (await this.resolveRuntimeFileTarget(worktree)).worktree.path, - launchEnv, - settings: this.requireStore().getSettings() - }) - return resolveStructuredCodexAccountHomePath({ - launchEnv, - resolveLaunchHome: this.prepareCodexStructuredLaunchFn - }) - } + const services = { + getClaudeConfigDirectory: (target) => this.accounts.getClaudeConfigDirectory(target), + prepareCodexLaunchHome: this.prepareCodexStructuredLaunchFn, + readCodexLaunchHome: this.resolveCodexStructuredLaunchHomeFn, + workspaceTrustSettings: () => this.requireStore().getSettings() } - return null + return async ({ launchEnv, location }) => + registration.resolveAccountHomePath( + { + launchEnv, + location: location ?? null, + purpose, + workspacePath: + purpose === 'launch' + ? async () => (await this.resolveRuntimeFileTarget(worktree)).worktree.path + : null + }, + services + ) } - /** The definition the installed host registered for `agent`: what its account home pins. */ - protected async requireRegisteredStructuredAgent(agent: StructuredAgentId) { - await this.ensureStructuredAgentSessionHost() - const definition = getStructuredAgentSessionHost()?.agentDefinition(agent) ?? null + /** The definition this runtime registers for `agent`: what its account home pins. Read from the + * registration list, so neither create nor a catalog read installs the host to learn it. */ + protected requireRegisteredStructuredAgent(agent: StructuredAgentId) { + const definition = structuredAgentRuntimeRegistration(agent)?.definition if (!definition) { throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostUnsupported' @@ -197,7 +174,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR reason: 'hostUnsupported' }) } - const definition = await this.requireRegisteredStructuredAgent(agent) + const definition = this.requireRegisteredStructuredAgent(agent) const launchEnv = resolveTuiAgentLaunchEnv( agent, this.requireStore().getSettings().agentDefaultEnv @@ -235,7 +212,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR const launchEnv = resolveTuiAgentLaunchEnv(input.agent, settings.agentDefaultEnv) const options = this.structuredAgentSessionLaunchSeedOptions(input.agent) const location = await this.resolveStructuredAgentSessionLocation(input.worktree) - const definition = await this.requireRegisteredStructuredAgent(input.agent) + const definition = this.requireRegisteredStructuredAgent(input.agent) const host = getStructuredAgentSessionHost() const committedReplay = resolveCommittedStructuredAgentSessionAdoptionIntent({ host, diff --git a/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts new file mode 100644 index 000000000000..400ead611f36 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts @@ -0,0 +1,87 @@ +// createSupport, create and the model catalog's account read ask the runtime's one registration +// list where an agent runs and which account it pins, before any host exists. An agent the list does +// not hold is answered no without installing the host. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + STRUCTURED_AGENT_STORAGE, + structuredAgentRuntimeRegistration +} from './structured-agent-runtime-registrations' + +afterEach(() => vi.restoreAllMocks()) + +const LOCAL = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const +} + +function runtimeAt(location = LOCAL) { + const runtime = new OrcaRuntimeService( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these reads consume only getSettings from the store. + { getSettings: () => ({ agentDefaultEnv: {} }) } as never + ) + const installHost = vi.fn(async () => { + throw new Error('the host must not be installed to answer this') + }) + Object.assign(runtime, { + resolveStructuredAgentSessionLocation: vi.fn(async () => location), + ensureStructuredAgentSessionHost: installHost + }) + return { runtime, installHost } +} + +describe('the structured agent registration list', () => { + it('is what the record store admits', () => { + expect([...STRUCTURED_AGENT_STORAGE.keys()]).toEqual( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) + ) + }) + + it("answers createSupport from the agent's own location rule", async () => { + const codex = structuredAgentRuntimeRegistration('codex')! + const { runtime, installHost } = runtimeAt() + expect(await runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'codex')).toEqual( + { supported: true } + ) + + vi.spyOn(codex, 'supportsLocation').mockReturnValue(false) + expect(await runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'codex')).toEqual( + { supported: false, reason: 'agent' } + ) + expect(codex.supportsLocation).toHaveBeenCalledWith(LOCAL) + expect(installHost).not.toHaveBeenCalled() + }) + + it('refuses an agent it does not hold without installing the host', async () => { + const { runtime, installHost } = runtimeAt() + + expect(await runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'grok')).toEqual({ + supported: false, + reason: 'agent' + }) + await expect(runtime.resolveStructuredAgentAccountHome('grok')).rejects.toMatchObject({ + message: 'structured_agent_session_unsupported' + }) + expect(installHost).not.toHaveBeenCalled() + }) + + it("resolves an account home through the agent's registration, with the read purpose", async () => { + const claude = structuredAgentRuntimeRegistration('claude')! + vi.spyOn(claude, 'resolveAccountHomePath').mockResolvedValue('/accounts/claude') + const { runtime, installHost } = runtimeAt() + + expect(await runtime.resolveStructuredAgentAccountHome('claude')).toEqual({ + variable: 'CLAUDE_CONFIG_DIR', + path: '/accounts/claude' + }) + expect(claude.resolveAccountHomePath).toHaveBeenCalledWith( + expect.objectContaining({ purpose: 'read', location: null, workspacePath: null }), + expect.anything() + ) + expect(installHost).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index bb183d65ba78..eba577611b3e 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -1,9 +1,14 @@ // The structured agents this runtime registers, in one list. Each entry's definition decides which -// agents' records the store admits, and its factory builds the adapter the router drives that agent -// with once the store is open, so storage and routing cannot disagree about which agents exist. +// agents' records the store admits, its factory builds the adapter the router drives that agent with +// once the store is open, and its location rule and account resolver answer the picker and create +// before any host exists, so none of them can disagree about which agents exist or where they run. // Adding an agent is one more entry. import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution' +import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' +import { supportsClaudeStructuredLocation } from '../claude/claude-structured-location-support' +import { applyStructuredCodexWorkspaceTrust } from '../agent-workspace-trust-spawn' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' @@ -24,6 +29,12 @@ import type { createStructuredAgentSessionDispatchFollowUps } from './structured import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' import type { createStructuredAgentEnvironmentResolvers } from './structured-agent-shell-environment' import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' +import { + resolveStructuredClaudeAccountHomePath, + resolveStructuredCodexAccountHomePath, + type StructuredClaudeAccountHomeDeps, + type StructuredCodexAccountHomeDeps +} from './structured-agent-account-home' /** What an agent's adapter is built from: the open store and the runtime around it. */ export type StructuredAgentAdapterContext = { @@ -43,9 +54,36 @@ export type StructuredAgentRuntimeAdapter = StructuredAgentSessionAdapter & { drainObservedExits?: () => Promise } +/** Which account home a chat of an agent pins, asked on the host that runs it. */ +export type StructuredAgentAccountHomeRequest = { + launchEnv: NodeJS.ProcessEnv + /** Where the chat runs; null for a read with no workspace (the model catalog). */ + location: AgentSessionExecutionLocation | null + /** A `read` has no side effects: it syncs no home, starts no bridge, clears no selection. */ + purpose: 'launch' | 'read' + /** The launch's workspace directory on this host; a read has none. */ + workspacePath: (() => Promise) | null +} + +/** What resolving an account may ask of the runtime around it. */ +export type StructuredAgentAccountHomeServices = { + getClaudeConfigDirectory: StructuredClaudeAccountHomeDeps['getClaudeConfigDirectory'] + /** Codex's home for a launch, prepared for it; and the same answer with no side effects. */ + prepareCodexLaunchHome: StructuredCodexAccountHomeDeps['resolveLaunchHome'] + readCodexLaunchHome: StructuredCodexAccountHomeDeps['resolveLaunchHome'] + workspaceTrustSettings: () => Parameters[0]['settings'] +} + export type StructuredAgentRuntimeRegistration = { definition: StructuredAgentDefinition createAdapter: (context: StructuredAgentAdapterContext) => StructuredAgentRuntimeAdapter + /** Whether this agent's chats can run at `location`; answered without building the host. */ + supportsLocation: (location: AgentSessionExecutionLocation) => boolean + /** The account home a chat of this agent pins; see `StructuredAgentAccountHomeRequest`. */ + resolveAccountHomePath: ( + request: StructuredAgentAccountHomeRequest, + services: StructuredAgentAccountHomeServices + ) => Promise } function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredAgentRuntimeAdapter { @@ -106,12 +144,56 @@ function createClaudeAdapter( }) } +async function resolveCodexAccountHomePath( + request: StructuredAgentAccountHomeRequest, + services: StructuredAgentAccountHomeServices +): Promise { + const { launchEnv, purpose, workspacePath } = request + if (purpose === 'launch' && workspacePath) { + await applyStructuredCodexWorkspaceTrust({ + workspacePath: await workspacePath(), + launchEnv, + settings: services.workspaceTrustSettings() + }) + } + return resolveStructuredCodexAccountHomePath({ + launchEnv, + resolveLaunchHome: + purpose === 'launch' ? services.prepareCodexLaunchHome : services.readCodexLaunchHome + }) +} + export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRuntimeRegistration[] = [ - { definition: CODEX_STRUCTURED_AGENT, createAdapter: createCodexAdapter }, - { definition: CLAUDE_STRUCTURED_AGENT, createAdapter: createClaudeAdapter } + { + definition: CODEX_STRUCTURED_AGENT, + createAdapter: createCodexAdapter, + supportsLocation: (location) => supportsCodexStructuredLocation(location), + resolveAccountHomePath: resolveCodexAccountHomePath + }, + { + definition: CLAUDE_STRUCTURED_AGENT, + createAdapter: createClaudeAdapter, + supportsLocation: supportsClaudeStructuredLocation, + resolveAccountHomePath: async ({ launchEnv, location }, services) => + resolveStructuredClaudeAccountHomePath({ + launchEnv, + wslDistro: location?.wslDistro ?? null, + getClaudeConfigDirectory: services.getClaudeConfigDirectory + }) + } ] +/** The registration of `agent`; null for an agent this runtime does not drive. */ +export function structuredAgentRuntimeRegistration( + agent: string +): StructuredAgentRuntimeRegistration | null { + return ( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.find(({ definition }) => definition.agent === agent) ?? + null + ) +} + /** What the record store admits: exactly the registered agents' declared storage. */ export const STRUCTURED_AGENT_STORAGE: AgentSessionStoredAgents = agentSessionStoredAgents( STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map((registration) => registration.definition) From a83313be89dc12743101908524fce85c8a6de11d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:47:10 -0700 Subject: [PATCH 153/269] test(agent-session): drop a duplicate registry key; keep fences out of the capsule state A spread input already carries the registry, so the explicit key was overwritten (TS2783). The session-fence helper now returns only the fence, not the capsule state it was given. --- ...structured-agent-session-processless-reservation.test.ts | 1 - src/main/runtime/agent-session-recovery-capsule-entries.ts | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index 9efc08c0409f..543abc1e2537 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -322,7 +322,6 @@ describe('processless structured session reservation', () => { await expect( performAttach({ - agents: NO_STRUCTURED_AGENTS, ...input, authority: { ...input.authority, diff --git a/src/main/runtime/agent-session-recovery-capsule-entries.ts b/src/main/runtime/agent-session-recovery-capsule-entries.ts index 2d7bfa4b24e5..e1c1a562ad61 100644 --- a/src/main/runtime/agent-session-recovery-capsule-entries.ts +++ b/src/main/runtime/agent-session-recovery-capsule-entries.ts @@ -111,13 +111,13 @@ function withSessionFences( for (const sessionId of sessionIds) { dismissedSessions.set(sessionId, Math.max(at, dismissedSessions.get(sessionId) ?? at)) } - return { ...fence, dismissedSessions } + return { dismissedAt: fence.dismissedAt, dismissedSessions } } /** A dismiss-all of `state`: every record but those `keep` answers true for goes, and stays gone. * An in-flight action's later complete/rollback becomes a no-op, and a late teardown writer is - * fenced: for every session, or with `keep` for each cleared one, so a session kept for a client - * that sees it is never fenced by one that does not. */ + * fenced: for every session, or with `keep` for each cleared one, so a dismissal never fences a + * chat its client was not shown. */ export function dismissAll( state: RecoveryCapsuleState, now: number, From b557e5e4f18ed76dd49266e5174309c136c53319 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:10:23 -0700 Subject: [PATCH 154/269] fix(agent-session): a scoped dismiss-all persists no per-session fence The per-session dismissal fence added a forever-persisted capsule field with an arbitrary cap that served no caller: only the local desktop calls the restart methods, and on a host whose agents it all shows it gets no audience and takes the unscoped, fenced dismissal. A scoped dismiss-all (a caller that cannot show some registered agent) keeps that agent's offers, clears only its audience's unwritten witnesses, and writes no fence; a late write from this process is serialized behind it. The field never left this unreleased branch. --- ...red-agent-session-restart-audience.test.ts | 3 +- ...ed-agent-session-restart-failure-ledger.ts | 29 ++--- .../agent-session-recovery-capsule-entries.ts | 102 +----------------- .../agent-session-recovery-capsule.test.ts | 53 --------- .../runtime/agent-session-recovery-capsule.ts | 51 +++++---- ...gent-session-restart-dismiss-fence.test.ts | 18 ++-- 6 files changed, 56 insertions(+), 200 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts index 909e7583e08c..f468d0bd12ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts @@ -74,7 +74,6 @@ it('keeps a failure the caller cannot show, and leaves it out of an action reply expect(await host.restartResume.dismiss(undefined, cannotShowCodex)).toBe(0) expect(await host.restartResume.listFailures()).toHaveLength(1) - // A dismiss-all counts pending offers, as it always has; the failure goes with it all the same. - expect(await host.restartResume.dismiss(undefined, showsCodex)).toBe(0) + expect(await host.restartResume.dismiss(undefined, showsCodex)).toBe(1) expect(await host.restartResume.listFailures()).toEqual([]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index 8c986463e82b..8697fdfb552a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts @@ -59,8 +59,8 @@ export type StructuredAgentSessionRestartFailureLedger = { failureReason: (sessionId: string) => string } ) => Promise - /** Named sessions forget their offer or failure; unnamed, every durable record goes and stays - * gone. With an audience, only records of agents it sees go, and only they are fenced. */ + /** Named sessions forget their offer or failure; unnamed, every durable record goes. With an + * audience, only records of agents it sees go, and an unnamed dismissal is no fence. */ dismiss: ( sessionIds: readonly string[] | undefined, beforeClearAll: (audience?: StructuredAgentSessionRestartAudience) => void, @@ -216,20 +216,25 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { settle, dismiss: (sessionIds, beforeClearAll, audience) => deps.enqueue(async () => { - // Decided under the capsule lock. A record whose chat this host cannot read names no - // agent the audience was shown, so it stays. - const hidden = (marker: AgentSessionResumeMarker) => { - if (!audience) { - return false + if (audience) { + // Decided under the capsule lock. A record whose chat this host cannot read names no + // agent the audience was shown, so it stays. + const hidden = (marker: AgentSessionResumeMarker) => { + const record = deps.getRecord(marker.sessionId) + return record === null || !audience(record.provider) } - const record = deps.getRecord(marker.sessionId) - return record === null || !audience(record.provider) + if (sessionIds === undefined) { + beforeClearAll(audience) + } + // No fence: no client reaches this today (the local desktop gets no audience), and a + // late write from this process is serialized behind the dismissal. + return (await deps.capsule?.dismiss(sessionIds ?? 'all', deps.now(), hidden)) ?? 0 } if (sessionIds !== undefined) { - return (await deps.capsule?.dismiss(sessionIds, deps.now(), hidden)) ?? 0 + return (await deps.capsule?.dismiss(sessionIds, deps.now())) ?? 0 } - beforeClearAll(audience) - return (await deps.capsule?.clearAll(deps.now(), audience ? hidden : undefined)) ?? 0 + beforeClearAll() + return (await deps.capsule?.clearAll(deps.now())) ?? 0 }) } } diff --git a/src/main/runtime/agent-session-recovery-capsule-entries.ts b/src/main/runtime/agent-session-recovery-capsule-entries.ts index e1c1a562ad61..a5d1d899723a 100644 --- a/src/main/runtime/agent-session-recovery-capsule-entries.ts +++ b/src/main/runtime/agent-session-recovery-capsule-entries.ts @@ -42,12 +42,8 @@ const capsuleSchema = z.object({ version: z.literal(2), entries: z.array(z.unknown()), dismissedAt: z.number().int().nonnegative().optional(), - dismissedSessions: z.unknown().optional(), failed: z.unknown().optional() }) -const sessionFencesSchema = z.record(z.string().min(1), z.number().int().nonnegative()) -/** Newest per-session fences kept; a dismiss-all fence supersedes every older one. */ -const MAX_SESSION_FENCES = 512 /** What an acted-on offer left behind when the agent did not carry on. Current only while nothing * newer happened in that chat; also dies with a dismissal, a successful retry, or a newer @@ -83,101 +79,7 @@ export type RecoveryEntry = { export type RecoveryCapsuleState = { entries: RecoveryEntry[] failed: AgentSessionResumeFailureRecord[] -} & RecoveryCapsuleFence - -/** What keeps a dismissed offer dismissed: a teardown witness recorded at or before the fence is - * dropped. `dismissedAt` fences every session (dismiss-all); `dismissedSessions` fences single - * sessions, for a dismiss-all scoped to the agents one client sees. */ -export type RecoveryCapsuleFence = { - dismissedAt?: number - dismissedSessions?: ReadonlyMap -} - -export function isFencedOut( - fence: RecoveryCapsuleFence, - marker: AgentSessionResumeMarker -): boolean { - const at = Math.max(fence.dismissedAt ?? -1, fence.dismissedSessions?.get(marker.sessionId) ?? -1) - return marker.recordedAt <= at -} - -/** `fence` with each of `sessionIds` fenced at `at`. */ -function withSessionFences( - fence: RecoveryCapsuleFence, - sessionIds: Iterable, - at: number -): RecoveryCapsuleFence { - const dismissedSessions = new Map(fence.dismissedSessions) - for (const sessionId of sessionIds) { - dismissedSessions.set(sessionId, Math.max(at, dismissedSessions.get(sessionId) ?? at)) - } - return { dismissedAt: fence.dismissedAt, dismissedSessions } -} - -/** A dismiss-all of `state`: every record but those `keep` answers true for goes, and stays gone. - * An in-flight action's later complete/rollback becomes a no-op, and a late teardown writer is - * fenced: for every session, or with `keep` for each cleared one, so a dismissal never fences a - * chat its client was not shown. */ -export function dismissAll( - state: RecoveryCapsuleState, - now: number, - keep?: (marker: AgentSessionResumeMarker) => boolean -): { - records: Pick - fence: RecoveryCapsuleFence - pendingCleared: number -} { - const { entries, failed } = normalizeState(state, now) - const kept = (record: { marker: AgentSessionResumeMarker }) => keep?.(record.marker) === true - const cleared = [...entries, ...failed].filter((record) => !kept(record)) - return { - records: { entries: entries.filter(kept), failed: failed.filter(kept) }, - fence: keep - ? withSessionFences( - state, - cleared.map((record) => record.marker.sessionId), - now - ) - : { dismissedAt: now }, - pendingCleared: entries.filter((entry) => entry.state === 'pending' && !kept(entry)).length - } -} - -/** The capsule file's contents. */ -export function storedCapsule( - { entries, failed }: Pick, - fence: RecoveryCapsuleFence -) { - return { - version: 2, - entries, - ...storedFence(fence), - ...(failed.length === 0 ? {} : { failed }) - } -} - -/** The fence as stored: a session fence the dismiss-all fence covers is dropped, and only the - * newest are kept, so the set cannot grow without bound. */ -function storedFence(fence: RecoveryCapsuleFence): { dismissedAt?: number - dismissedSessions?: Record -} { - const sessions = [...(fence.dismissedSessions ?? new Map())] - .filter(([, at]) => fence.dismissedAt === undefined || at > fence.dismissedAt) - .sort(([, left], [, right]) => right - left) - .slice(0, MAX_SESSION_FENCES) - return { - ...(fence.dismissedAt === undefined ? {} : { dismissedAt: fence.dismissedAt }), - ...(sessions.length === 0 ? {} : { dismissedSessions: Object.fromEntries(sessions) }) - } -} - -// Advisory like failures: an unreadable set reads as none rather than costing every offer. -function parseSessionFences(value: unknown): ReadonlyMap | undefined { - const parsed = sessionFencesSchema.safeParse(value) - return parsed.success && Object.keys(parsed.data).length > 0 - ? new Map(Object.entries(parsed.data)) - : undefined } function parseMarker(value: unknown): AgentSessionResumeMarker { @@ -242,12 +144,10 @@ export function parseState(raw: string): RecoveryCapsuleState { } } const capsule = capsuleSchema.parse(value) - const dismissedSessions = parseSessionFences(capsule.dismissedSessions) return { entries: capsule.entries.map(parseEntry), failed: parseFailures(capsule.failed), - ...(capsule.dismissedAt === undefined ? {} : { dismissedAt: capsule.dismissedAt }), - ...(dismissedSessions ? { dismissedSessions } : {}) + ...(capsule.dismissedAt === undefined ? {} : { dismissedAt: capsule.dismissedAt }) } } diff --git a/src/main/runtime/agent-session-recovery-capsule.test.ts b/src/main/runtime/agent-session-recovery-capsule.test.ts index 8d617f115534..bb3c93a6a989 100644 --- a/src/main/runtime/agent-session-recovery-capsule.test.ts +++ b/src/main/runtime/agent-session-recovery-capsule.test.ts @@ -516,59 +516,6 @@ describe('durable restart offers', () => { expect(await capsule.list(NOW + 2)).toEqual([newer]) }) - it('fences each session a scoped dismiss-all clears, and leaves what it keeps unfenced', async () => { - const hidden = marker({ sessionId: 'hidden' }) - await capsule.record([marker(), hidden], NOW) - await capsule.beginResume([SESSION], 'operation-a', NOW) - - expect(await capsule.clearAll(NOW + 1, (kept) => kept.sessionId === 'hidden')).toBe(0) - expect(await capsule.list(NOW + 1)).toEqual([hidden]) - await capsule.rollbackResume('operation-a', NOW + 1) - // The stale teardown write of the cleared chat stays out; the kept chat still takes a newer one. - const newerHidden = marker({ sessionId: 'hidden', recordedAt: NOW + 1, teardownId: 'later' }) - await capsule.record([marker(), newerHidden], NOW + 1) - expect(await capsule.list(NOW + 1)).toEqual([newerHidden]) - - const newer = marker({ recordedAt: NOW + 2, teardownId: 'teardown-new' }) - await capsule.record([newer], NOW + 2) - expect(await capsule.list(NOW + 2)).toContainEqual(newer) - await capsule.record([marker()], NOW + 2) - expect(await capsule.list(NOW + 2)).toContainEqual(newer) - }) - - it('drops session fences a dismiss-all fence covers, and keeps the newest of the rest', async () => { - await capsule.record([marker()], NOW) - await capsule.clearAll(NOW, () => false) - expect(JSON.parse(await readFile(filePath, 'utf8'))).toMatchObject({ - dismissedSessions: { [SESSION]: NOW } - }) - - await capsule.clearAll(NOW + 1) - expect(JSON.parse(await readFile(filePath, 'utf8'))).not.toHaveProperty('dismissedSessions') - - const many = Array.from({ length: 600 }, (_, index) => - marker({ sessionId: `session-${index}`, recordedAt: NOW + 2 + index }) - ) - await capsule.record(many, NOW + 2) - for (const [index, each] of many.entries()) { - await capsule.clearAll(NOW + 2 + index, (kept) => kept.sessionId !== each.sessionId) - } - const fences = JSON.parse(await readFile(filePath, 'utf8')).dismissedSessions - expect(Object.keys(fences)).toHaveLength(512) - expect(fences).toHaveProperty('session-599') - expect(fences).not.toHaveProperty('session-0') - }) - - it('lets a scoped dismiss-all replace an unreadable file with an empty fence', async () => { - await writeFile(filePath, '{') - - expect(await capsule.clearAll(NOW, () => true)).toBe(0) - expect(JSON.parse(await readFile(filePath, 'utf8'))).toMatchObject({ - entries: [], - dismissedAt: NOW - }) - }) - it('keeps a legacy v1 file readable until a mutating operation migrates it', async () => { const legacy = JSON.stringify({ version: 1, markers: [marker()] }) await writeFile(filePath, legacy) diff --git a/src/main/runtime/agent-session-recovery-capsule.ts b/src/main/runtime/agent-session-recovery-capsule.ts index ee7002558e58..7343ab64e8e1 100644 --- a/src/main/runtime/agent-session-recovery-capsule.ts +++ b/src/main/runtime/agent-session-recovery-capsule.ts @@ -11,14 +11,10 @@ import { } from '../durable-file-write' import { withFileTransactionLock } from '../file-transaction-lock' import { - dismissAll, - isFencedOut, MAX_FAILURE_FIELD_LENGTH, normalizeState, parseState, shouldReplaceMarker, - storedCapsule, - type RecoveryCapsuleFence, type AgentSessionResumeFailureInput, type AgentSessionResumeFailureRecord, type RecoveryCapsuleState, @@ -68,8 +64,9 @@ export class AgentSessionRecoveryCapsule { const { entries, failed } = normalizeState(state, now) const bySession = new Map(entries.map((entry) => [entry.marker.sessionId, entry])) const failedBySession = new Map(failed.map((failure) => [failure.marker.sessionId, failure])) + const dismissedAt = state.dismissedAt for (const marker of markers) { - if (isFencedOut(state, marker)) { + if (dismissedAt !== undefined && marker.recordedAt <= dismissedAt) { continue } const existing = bySession.get(marker.sessionId) @@ -95,7 +92,7 @@ export class AgentSessionRecoveryCapsule { } // Keep the fence after a newer interruption. It still admits genuinely newer markers, // while an older delayed writer remains unable to resurrect a dismissed chat later. - await this.publish({ entries: [...bySession.values()], failed }, now, state) + await this.publish({ entries: [...bySession.values()], failed }, now, dismissedAt) }) } @@ -128,7 +125,7 @@ export class AgentSessionRecoveryCapsule { next.push(reserve(failure.marker)) } } - await this.publish({ entries: next, failed }, now, state) + await this.publish({ entries: next, failed }, now, state.dismissedAt) return selected }) } @@ -153,7 +150,7 @@ export class AgentSessionRecoveryCapsule { failed: failed.filter((failure) => !completed.has(failure.marker.sessionId)) }, now, - state + state.dismissedAt ) }) } @@ -197,7 +194,7 @@ export class AgentSessionRecoveryCapsule { ] }, now, - state + state.dismissedAt ) }) } @@ -228,7 +225,7 @@ export class AgentSessionRecoveryCapsule { failed: failed.filter((failure) => !dismissed.has(failure.marker.sessionId)) }, now, - state + state.dismissedAt ) } return dismissed.size @@ -263,7 +260,7 @@ export class AgentSessionRecoveryCapsule { ) ) if (keptEntries.length !== entries.length || keptFailures.length !== failed.length) { - await this.publish({ entries: keptEntries, failed: keptFailures }, now, state) + await this.publish({ entries: keptEntries, failed: keptFailures }, now, state.dismissedAt) } }) } @@ -278,23 +275,27 @@ export class AgentSessionRecoveryCapsule { : entry ) // Normalizing on publish keeps a rolled-back retry a failure rather than a pending offer. - await this.publish({ entries: next, failed }, now, state) + await this.publish({ entries: next, failed }, now, state.dismissedAt) }) } - /** The explicit request to forget every record, or all but `keep`'s; see `dismissAll`. */ - clearAll(now: number, keep?: (marker: AgentSessionResumeMarker) => boolean): Promise { + clearAll(now: number): Promise { return withFileTransactionLock(this.filePath, async () => { - let dismissal: ReturnType + let entries: RecoveryEntry[] try { - dismissal = dismissAll(await this.readState(), now, keep) + entries = normalizeState(await this.readState(), now).entries } catch { - // Unreadable bytes hold no offer anyone sees; the dismissal must still land, fenced. - await this.publish({ entries: [], failed: [] }, now, { dismissedAt: now }) + // Dismiss is an explicit request to forget this advisory file. Replace unreadable bytes + // with an empty, fenced capsule so a late teardown writer cannot resurrect the offer. + await this.publish({ entries: [], failed: [] }, now, now) return 0 } - await this.publish(dismissal.records, now, dismissal.fence) - return dismissal.pendingCleared + const pending = entries.filter((entry) => entry.state === 'pending') + // Dismiss is the explicit user request to forget every recovery record. An in-flight + // action may still finish, but its later complete/rollback becomes a no-op and cannot + // resurrect a row the user dismissed. + await this.publish({ entries: [], failed: [] }, now, now) + return pending.length }) } @@ -320,10 +321,16 @@ export class AgentSessionRecoveryCapsule { private async publish( records: StoredRecords, now: number, - fence: RecoveryCapsuleFence + dismissedAt: number | undefined ): Promise { + const { entries, failed } = normalizeState(records, now) const { serialized } = stringifyJsonWithinByteLimit( - storedCapsule(normalizeState(records, now), fence), + { + version: 2, + entries, + ...(dismissedAt === undefined ? {} : { dismissedAt }), + ...(failed.length === 0 ? {} : { failed }) + }, MAX_CAPSULE_BYTES ) await removeStaleDurableWriteTempFiles(this.filePath, { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts index 8c14b946ce8b..9937a3eb3d89 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts @@ -1,6 +1,7 @@ // "Dismiss all" from the desktop prompt, through the context the desktop renderer really dispatches -// with: a paired runtime client without the registered-agents capability. A dismissal must stay -// final for every offer that client was shown, and leave an agent it cannot show alone. +// with: a paired runtime client without the registered-agents capability. On a host whose agents it +// all shows, the dismissal is the unscoped, fenced one; one that cannot show some agent leaves that +// agent's offers alone. import { readFile } from 'node:fs/promises' import { join } from 'node:path' @@ -50,13 +51,10 @@ function withPilot(): StructuredAgentRegistry { } async function storedFence(root: string) { - const { dismissedAt, dismissedSessions } = JSON.parse( + const { dismissedAt } = JSON.parse( await readFile(join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), 'utf8') ) - return { - ...(dismissedAt ? { dismissedAt } : {}), - ...(dismissedSessions ? { dismissedSessions } : {}) - } + return dismissedAt ? { dismissedAt } : {} } async function dismissAllFromDesktop() { @@ -81,7 +79,7 @@ it('fences a dismissed offer against a late teardown write when the desktop sees }) }) -it('keeps a scoped dismiss-all final for what the desktop was shown when the host runs an agent it cannot show', async () => { +it('leaves offers it was not shown when the host runs an agent the desktop cannot show', async () => { const { host, root, marker } = await interruptedRestart( undefined, undefined, @@ -95,8 +93,8 @@ it('keeps a scoped dismiss-all final for what the desktop was shown when the hos await capsule.record([unreadable], NOW) expect(await dismissAllFromDesktop()).toMatchObject({ ok: true, result: { dismissed: 1 } }) - await capsule.record([marker!], NOW) expect(await capsule.list(NOW)).toEqual([unreadable]) - expect(await storedFence(root)).toEqual({ dismissedSessions: { [marker!.sessionId]: NOW + 1 } }) + // A scoped dismissal writes no fence: nothing it did not clear may be dropped later. + expect(await storedFence(root)).toEqual({}) }) From f0e695f83ebf79103b5852fc779d51d479d99473 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:17:28 -0700 Subject: [PATCH 155/269] fix(agent-session): refuse an attach whose agent is not the session's own The start check judges the record's agent while the router starts the adapter of params.agent, and the attach wire let the two differ. Admission now refuses agent !== provider as requestMalformed, so the agent checked is the agent started. Every host-built attach already sets them equal. --- .../structured-agent-session-attach.ts | 11 ++++++ ...ructured-agent-session-drivability.test.ts | 35 +++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index f31e48c1eef3..d6780082433a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -122,6 +122,17 @@ export function attachFingerprintFields(params: AgentSessionAttachParams): Recor export function admitAttachOrRefuse( params: AgentSessionAttachParams ): { ok: true; fingerprint: string } | { ok: false; refusal: AgentSessionWireRefusal } { + // The start check judges the record's agent and the router starts `agent`'s adapter: one agent. + if (params.agent !== params.provider) { + return { + ok: false, + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'requestMalformed' }, + `A ${params.provider} session cannot be started as ${params.agent}.` + ) + } + } if ( params.providerHandle && !agentSessionProviderHandleBelongsTo( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts index db3d812de992..1fba19a4a1aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts @@ -9,7 +9,6 @@ import { afterEach, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionAccountHome } from '../../../shared/agent-session-record' import { agentSessionStoredAgents } from '../../../shared/agent-session-stored-agent' -import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../../shared/agent-session-stored-agent.test-fixture' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' @@ -54,7 +53,7 @@ const GROK: StructuredAgentDefinition = { effortDefaultsToModel: false } } -const STORAGE = agentSessionStoredAgents([...CLAUDE_AND_CODEX_STORED_AGENTS.values(), GROK]) +const STORAGE = agentSessionStoredAgents([{ agent: 'claude' }, { agent: 'codex' }, GROK]) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only the methods a declaration needs; this one declares none of them. const NO_METHODS = {} as StructuredAgentSessionAdapter @@ -163,7 +162,7 @@ async function createdGrokChat(relaunched: StructuredAgentDefinition = GROK) { }) expect(created.result).toMatchObject({ ok: true }) const store = await openTestAgentSessionRecordStore(root, { - agents: agentSessionStoredAgents([...CLAUDE_AND_CODEX_STORED_AGENTS.values(), relaunched]) + agents: agentSessionStoredAgents([{ agent: 'claude' }, { agent: 'codex' }, relaunched]) }) await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW + 1 }) return { store, fence: store.getRecord(SESSION)!.lease.runtimeFence } @@ -229,3 +228,33 @@ it('starts the same chat again once its agent is declared as it was', async () = expect(result).toMatchObject({ ok: true }) expect(adapter.acquire).toHaveBeenCalledOnce() }) + +it('refuses an attach whose agent is not the session it names', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) + const adapter = grokAdapter() + const mismatched = { ...params('1', null), agent: 'codex' } + + const { result, store } = await attach({ + agents: grokRegistered(), + adapter, + params: { + ...mismatched, + envelope: { + ...mismatched.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(mismatched) + }) + } + }, + spawnToken: 'spawn-a' + }) + + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid', details: { reason: 'requestMalformed' } } + }) + expect(adapter.acquire).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)).toBeNull() +}) From 7b52fb5c1c6757edeeb8a17bfbda7ed9dabe5e87 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:17:28 -0700 Subject: [PATCH 156/269] fix(agent-session): offer to start a chat only when the start would accept it The restart offer, a failure's retryable flag and the pre-send check asked only whether the adapter runs the chat's location, while the start also refuses a record this build cannot drive; such a chat was offered Resume and its Retry failed forever. One host predicate, hostCanStartRecord (location support and agentDrivesSession), now answers all four; adapterSupportsRecord stays the reader gate, and an undrivable chat's offer is kept, not retired. --- .../structured-agent-session-agent-start.ts | 4 +- .../structured-agent-session-attach-flow.ts | 7 +- ...ructured-agent-session-provider-support.ts | 13 ++++ ...ctured-agent-session-restart-candidates.ts | 8 ++- ...tured-agent-session-restart-resume-host.ts | 16 ++--- ...d-agent-session-restart-undrivable.test.ts | 72 +++++++++++++++++++ 6 files changed, 102 insertions(+), 18 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index d2ef237aa1b6..2c718ba3fc23 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -27,7 +27,7 @@ import { terminalOwnerRefusalMessage } from '../../../shared/agent-session-legac import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { attachStructuredAgentSessionUnderSerialize } from './structured-agent-session-attach-orchestration' import { failedCreateRefusal } from './structured-agent-session-failed-create-refusal' -import { adapterSupportsRecord } from './structured-agent-session-provider-support' +import { hostCanStartRecord } from './structured-agent-session-provider-support' import { finishOwedStructuredAgentSessionWindDownUnderSerialize, type StructuredAgentSessionLifetimeContext @@ -166,7 +166,7 @@ async function startStructuredAgentSessionAgent( 'No structured session exists by that id.' ) } - if (!adapterSupportsRecord(context.deps.adapter, record)) { + if (!hostCanStartRecord(context.deps, record)) { return refuseResume( 'structured_agent_session_unsupported', { reason: 'hostUnsupported' }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index f03e2c1b655e..fc6bb15a1f98 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -31,7 +31,7 @@ import { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { adapterSupportsCreateIfDeclared, - agentDrivesSession + hostCanStartRecord } from './structured-agent-session-provider-support' import type { StructuredAgentRegistry } from './structured-agent-registry' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' @@ -106,8 +106,9 @@ export async function performAttach( // Every start of every agent passes here, so this is where a record this build cannot drive (its // transport or account variable is not its agent's) is refused; reading it never is. const supported = (record: AgentSessionRecord | null) => - adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent) && - (record === null || agentDrivesSession(input.agents, record)) + record === null + ? adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent) + : hostCanStartRecord(input, record) // Ensure/recovery bypass create-intent, so recheck before reserving or spawning. if (!supported(store.getRecord(sessionId))) { return unsupported() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts index 8c5237994c83..c6db59498ea1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts @@ -59,3 +59,16 @@ export function agentDrivesSession( ) ) } + +/** Whether this host can start `record`'s agent: the adapter runs its location and this build + * drives it. The restart offer, a retry, the pre-send check and the start itself all ask this, so + * none offers what the start refuses; reading the chat asks only `adapterSupportsRecord`. */ +export function hostCanStartRecord( + deps: { + adapter: StructuredAgentSessionAdapter + agents: Pick + }, + record: AgentSessionRecord +): boolean { + return adapterSupportsRecord(deps.adapter, record) && agentDrivesSession(deps.agents, record) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts index b16f76c1633b..29b4e62b431e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts @@ -2,14 +2,15 @@ // // A different question from storage: the durable record decides which markers are still present; // this decides which of those a resume may act on. The offer, the click and the pre-send check all -// ask it, and all get the same answer. +// ask it, and the start asks the same `hostCanStartRecord`, so none offers what the start refuses. import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-latest-request' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { adapterSupportsRecord } from './structured-agent-session-provider-support' +import { hostCanStartRecord } from './structured-agent-session-provider-support' +import type { StructuredAgentRegistry } from './structured-agent-registry' import { structuredAgentSessionResumableSet, type StructuredAgentSessionResumableSet @@ -28,6 +29,7 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: { sessions: ReadonlyMap getRecord: (sessionId: string) => AgentSessionRecord | null adapter: StructuredAgentSessionAdapter + agents: Pick /** Whether the chat moved on since the offer was taken; see the offer withdrawal. */ movedOn: (marker: AgentSessionResumeMarker) => boolean }): StructuredAgentSessionRestartCandidateReader { @@ -35,7 +37,7 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: { structuredAgentSessionResumableSet({ markers, getRecord: deps.getRecord, - supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), + supportsRecord: (record) => hostCanStartRecord(deps, record), movedOn: deps.movedOn, latestPrompt: (sessionId) => latestStructuredAgentSessionPrompt( 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 ac91a00f8723..62b981971899 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,13 +7,10 @@ import { AgentSessionRefusalError, agentSessionRefusalFromReference } from '../../../shared/agent-session-wire-refusals' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import type { AgentSessionResumeMarker, AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { createStructuredAgentSessionRestartCandidateReader } from './structured-agent-session-restart-candidates' import { continuationFailureOutcome, @@ -46,7 +43,7 @@ import { import type { StructuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' import { createStructuredAgentSessionRestartWitnesses } from './structured-agent-session-restart-witnesses' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' type LiveSession = StructuredAgentSessionRestartOfferSession @@ -86,12 +83,10 @@ export type StructuredAgentSessionRestartResume = { } export function createStructuredAgentSessionRestartResume( - deps: { - store: AgentSessionRecordStore - adapter: StructuredAgentSessionAdapter - recoveryCapsule?: AgentSessionRecoveryCapsule - logger: StructuredAgentSessionLogger - }, + deps: Pick< + StructuredAgentSessionHostDeps, + 'store' | 'adapter' | 'recoveryCapsule' | 'logger' | 'agents' + >, sessions: ReadonlyMap, surfaces: StructuredAgentSessionRestartResumeSurfaces ): StructuredAgentSessionRestartResume { @@ -118,6 +113,7 @@ export function createStructuredAgentSessionRestartResume( sessions, getRecord: deps.store.getRecord, adapter: deps.adapter, + agents: deps.agents, movedOn: withdrawal.movedOn }) const failures = createStructuredAgentSessionRestartFailureLedger({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts new file mode 100644 index 000000000000..11d23efa8048 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts @@ -0,0 +1,72 @@ +// A chat whose agent this build no longer drives (its definition now names another transport) is +// still readable, but nothing offers to start it: no restart offer, and a recorded failure is not +// retryable. The offer itself is kept, so a build that drives the agent again offers it again. + +import { afterEach, expect, it, vi } from 'vitest' +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { HOST_TEST_NOW as NOW } from './structured-agent-session-host-test-data' +import { StructuredAgentRegistry } from './structured-agent-registry' +import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' + +afterEach(() => vi.restoreAllMocks()) + +/** This build's agents, with Codex now speaking a protocol its existing chats were not made in. */ +function codexOnAnotherTransport(): StructuredAgentRegistry { + return new StructuredAgentRegistry( + NO_STRUCTURED_AGENTS.registrations().map((registration) => + registration.definition.agent === 'codex' + ? { + ...registration, + definition: { ...registration.definition, handleTransport: 'codex-acp' } + } + : registration + ) + ) +} + +it('does not offer to resume a chat whose agent this build no longer drives, and keeps the offer', async () => { + const { host, root, acquire } = await interruptedRestart( + undefined, + undefined, + undefined, + codexOnAnotherTransport() + ) + + expect(await host.restartResume.list()).toEqual([]) + expect(await host.restartResume.continueAfterRestart(undefined, 'modal')).toMatchObject({ + resumed: [], + continued: [] + }) + expect(acquire).not.toHaveBeenCalled() + expect(await new AgentSessionRecoveryCapsule(root).list(NOW + 1)).toHaveLength(1) +}) + +it('lists a recorded failure of such a chat as not retryable', async () => { + const { host, root, marker } = await interruptedRestart( + undefined, + undefined, + undefined, + codexOnAnotherTransport() + ) + const capsule = new AgentSessionRecoveryCapsule(root) + await capsule.beginResume([marker!.sessionId], 'operation-a', NOW + 1) + await capsule.failResume( + 'operation-a', + [ + { + sessionId: marker!.sessionId, + failedAt: NOW + 1, + outcome: 'refused', + reason: 'structured_agent_session_unsupported', + latestPrompt: '', + latestUserItemId: null + } + ], + NOW + 1 + ) + + expect(await host.restartResume.listFailures()).toMatchObject([ + { sessionId: marker!.sessionId, retryable: false } + ]) +}) From 38d510dbbc23bb9649bad3749f2897447c2e1aa7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:17:28 -0700 Subject: [PATCH 157/269] fix(agent-session): the model-catalog probe uses a record's account only if this build drives it A record-scoped catalog read started the agent's lister under the record's account path whatever variable the record pinned. It now uses that path only when agentDrivesSession holds, and otherwise resolves the account as for a read with no record. --- ...-structured-effort-default-at-rest.test.ts | 1 + .../agent-model-catalog-service.test.ts | 30 +++++++++++++++++++ .../agent-model-catalog-service.ts | 6 +++- ...uctured-agent-session-options-read.test.ts | 1 + .../structured-agent-model-catalog-wiring.ts | 4 +++ .../structured-agent-session-runtime.ts | 2 +- 6 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index edb7c2d0919a..92cf562b9dcd 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -144,6 +144,7 @@ function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { const modelCatalog = createAgentModelCatalogService({ store, getRecord: () => record, + drivesRecord: () => true, resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }) }) const resting = { diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts index 020254851513..84a2649bee23 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts @@ -55,6 +55,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => record('/homes/a'), + drivesRecord: () => true, resolveAccountHome: async () => CODEX_HOME('/homes/selected'), probes: { codex: probe } }) @@ -73,6 +74,26 @@ describe('agent model catalog service', () => { }) }) + it('probes as for no record when this build cannot start the record as it is pinned', async () => { + const store = new AgentModelCatalogStore() + const probe = vi.fn(async (_home: string) => listing('gpt-a')) + // A Codex record pinning Claude's variable: its path is not a Codex home to probe under. + const pinned = { ...record('/x'), accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/x' } } + const drivesRecord = vi.fn(() => false) + const service = createAgentModelCatalogService({ + store, + getRecord: () => pinned, + drivesRecord, + resolveAccountHome: async () => CODEX_HOME('/homes/selected'), + probes: { codex: probe } + }) + + await service.read({ agent: 'codex', sessionId: 'session-1' }) + + expect(drivesRecord).toHaveBeenCalledWith(pinned) + expect(probe).toHaveBeenCalledExactlyOnceWith('/homes/selected') + }) + it('an account switch with no record reads and prewarms the NEW account, never the old entry', async () => { const store = new AgentModelCatalogStore() // The old account listed under its own fingerprint before the switch. @@ -82,6 +103,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => undefined, + drivesRecord: () => true, resolveAccountHome: async () => CODEX_HOME('/homes/new'), probes: { codex: probe } }) @@ -107,6 +129,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => undefined, + drivesRecord: () => true, resolveAccountHome: async () => CODEX_HOME('/homes/selected') }) const result = await service.read({ agent: 'codex' }) @@ -129,6 +152,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => sessionRecord, + drivesRecord: () => true, resolveAccountHome: async () => CODEX_HOME('/homes/selected') }) const result = await service.read({ agent: 'codex', sessionId: 'session-1' }) @@ -143,6 +167,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => record('/homes/a'), + drivesRecord: () => true, resolveAccountHome: async () => CODEX_HOME('/homes/a'), probes: { codex: probe } }) @@ -164,6 +189,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => undefined, + drivesRecord: () => true, resolveAccountHome: async () => { throw new Error('no store yet') }, @@ -189,6 +215,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => undefined, + drivesRecord: () => true, resolveAccountHome: async () => CODEX_HOME('/homes/selected'), probes: { codex: probe } }) @@ -236,6 +263,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => undefined, + drivesRecord: () => true, resolveAccountHome: async () => CODEX_HOME('/homes/selected') }) expect(await service.read({ agent: 'codex', waitForListing: true })).toEqual({ @@ -252,6 +280,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => undefined, + drivesRecord: () => true, resolveAccountHome: async () => CODEX_HOME('/homes/selected'), probes: { codex: probe } }) @@ -269,6 +298,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => undefined, + drivesRecord: () => true, resolveAccountHome: async () => CODEX_HOME('/homes/selected'), workspaceMayOverrideDefaultModel }) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index 176f3fcb59c4..4942ad68fd62 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -16,6 +16,9 @@ import type { export type AgentModelCatalogServiceDeps = { store: AgentModelCatalogStore getRecord: (sessionId: string) => AgentSessionRecord | undefined + /** Whether this build can start the record's agent as the record pins it; a record it cannot + * names no account a probe may start that agent's CLI under. */ + drivesRecord: (record: AgentSessionRecord) => boolean /** The account home a structured launch for this agent would pin right now — * the SAME resolver the create path fills `record.accountHome` with, so a * record-less read can never answer from another account's listing. */ @@ -92,7 +95,8 @@ export function createAgentModelCatalogService( return { async read(params) { const record = params.sessionId ? deps.getRecord(params.sessionId) : undefined - const scoped = record && record.provider === params.agent ? record : undefined + const scoped = + record && record.provider === params.agent && deps.drivesRecord(record) ? record : undefined let fingerprint: string let accountHomePath: string | null if (scoped) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index fbb3801d0e0e..5a458e6f2e9f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -30,6 +30,7 @@ describe('options at rest', () => { const modelCatalog = createAgentModelCatalogService({ store: new AgentModelCatalogStore(), getRecord: () => record, + drivesRecord: () => true, resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/homes/a' }), probes: { codex: probe } }) diff --git a/src/main/runtime/structured-agent-model-catalog-wiring.ts b/src/main/runtime/structured-agent-model-catalog-wiring.ts index c7c79aebe614..0b537dd04893 100644 --- a/src/main/runtime/structured-agent-model-catalog-wiring.ts +++ b/src/main/runtime/structured-agent-model-catalog-wiring.ts @@ -12,6 +12,8 @@ import type { ClaudeStructuredLaunchResolverDeps } from '../claude/claude-struct import type { CodexStructuredLaunchResolverDeps } from '../codex/codex-structured-launch-resolution' import type { AgentSessionRecordStore } from './agent-session-record-store' import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' +import type { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' +import { agentDrivesSession } from '../native-chat/agent-session-wire/structured-agent-session-provider-support' // The store is process-global; hydrate it from disk at most once per process. let persistenceAttached = false @@ -44,6 +46,7 @@ export async function attachAgentModelCatalogPersistenceOnce( */ export async function modelCatalogHostDeps(input: { store: Pick + agents: Pick deps: Pick< StructuredAgentSessionRuntimeDeps, | 'stateDirectory' @@ -68,6 +71,7 @@ export async function modelCatalogHostDeps(input: { const modelCatalog = createAgentModelCatalogService({ store: agentModelCatalogStore, getRecord: (sessionId) => input.store.getRecord(sessionId) ?? undefined, + drivesRecord: (record) => agentDrivesSession(input.agents, record), resolveAccountHome: deps.resolveAgentAccountHome, workspaceMayOverrideDefaultModel, probes: { diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index b7cb095c63b8..f409d5495eb7 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -293,7 +293,7 @@ async function installOnJournal( ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), - ...(await modelCatalogHostDeps({ store, deps, envResolvers })) + ...(await modelCatalogHostDeps({ store, agents, deps, envResolvers })) }) setStructuredAgentSessionHost(host) return { From 1a569b82f7795967daa55832e747bff010fe0654 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:17:28 -0700 Subject: [PATCH 158/269] refactor(agent-session): the record store admits agent ids; comments say where transport is checked The store only ever asked whether an agent is registered, so its admission list is now the registered ids; the definition is the one home of an agent's transport and account variable. Comments that said the record store checks transport, and one that cited a nonexistent function, now point at agentDrivesSession and the attach admission. The isPersistedAgentSessionRecord(value, agents) call shape and the test fixture the cross-version probe imports are kept. --- .../claude-structured-launch-resolution.ts | 2 +- ...tructured-agent-stored-definitions.test.ts | 22 +++++++++------ src/shared/agent-session-account-home.test.ts | 13 ++------- .../agent-session-provider-handle-encoding.ts | 7 +++-- src/shared/agent-session-record.ts | 2 +- ...agent-session-stored-agent.test-fixture.ts | 18 ++---------- src/shared/agent-session-stored-agent.test.ts | 2 +- src/shared/agent-session-stored-agent.ts | 28 +++++++++++-------- 8 files changed, 42 insertions(+), 52 deletions(-) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 6fea15d79fb7..e20c1bd8b8ee 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -266,7 +266,7 @@ export function createClaudeStructuredLaunchResolver( gate && hasWslBoundClaudeAccount(gate) ? { reason: 'managedAccountUnsupported' } : {} ) } - // A Claude record's chain holds only Claude handles; the record store refuses anything else. + // A Claude record's chain holds only Claude handles; the attach admission refuses anything else. const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle ?? null if ( head && diff --git a/src/main/runtime/structured-agent-stored-definitions.test.ts b/src/main/runtime/structured-agent-stored-definitions.test.ts index c3e7fe0927d1..31510e5cb6a5 100644 --- a/src/main/runtime/structured-agent-stored-definitions.test.ts +++ b/src/main/runtime/structured-agent-stored-definitions.test.ts @@ -2,21 +2,25 @@ import { describe, expect, it } from 'vitest' import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' +import { + CLAUDE_STRUCTURED_HANDLE_NAMESPACE, + CODEX_STRUCTURED_HANDLE_NAMESPACE +} from '../../shared/agent-session-provider-handle-encoding' import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, STRUCTURED_AGENT_STORAGE } from './structured-agent-runtime-registrations' describe('what the shipped definitions let a record store', () => { - it('matches the storage every older build wrote', () => { - for (const definition of [CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT]) { - const { agent, handleTransport, accountHomeVariable } = definition - expect({ agent, handleTransport, accountHomeVariable }).toEqual( - CLAUDE_AND_CODEX_STORED_AGENTS.get(agent) - ) - } - expect(CLAUDE_STRUCTURED_AGENT.accountHomeVariable).toBe('CLAUDE_CONFIG_DIR') - expect(CODEX_STRUCTURED_AGENT.accountHomeVariable).toBe('CODEX_HOME') + it('pins what every older build wrote', () => { + expect(CLAUDE_STRUCTURED_AGENT).toMatchObject({ + handleTransport: CLAUDE_STRUCTURED_HANDLE_NAMESPACE.transport, + accountHomeVariable: 'CLAUDE_CONFIG_DIR' + }) + expect(CODEX_STRUCTURED_AGENT).toMatchObject({ + handleTransport: CODEX_STRUCTURED_HANDLE_NAMESPACE.transport, + accountHomeVariable: 'CODEX_HOME' + }) }) it('admits exactly the agents the runtime routes, stored as before', () => { diff --git a/src/shared/agent-session-account-home.test.ts b/src/shared/agent-session-account-home.test.ts index 0c5c63f34f9b..a59ad59f43a7 100644 --- a/src/shared/agent-session-account-home.test.ts +++ b/src/shared/agent-session-account-home.test.ts @@ -7,14 +7,10 @@ import { agentSessionStoredAgents } from './agent-session-stored-agent' import { CLAUDE_AND_CODEX_STORED_AGENTS } from './agent-session-stored-agent.test-fixture' describe('agent session account home', () => { - it('pins the variables older builds wrote and read', () => { - expect(CLAUDE_AND_CODEX_STORED_AGENTS.get('claude')?.accountHomeVariable).toBe( - 'CLAUDE_CONFIG_DIR' - ) - expect(CLAUDE_AND_CODEX_STORED_AGENTS.get('codex')?.accountHomeVariable).toBe('CODEX_HOME') + it('stores the variable and path exactly as older builds wrote them', () => { expect( JSON.stringify( - agentSessionAccountHome(CLAUDE_AND_CODEX_STORED_AGENTS.get('codex')!, '/home/dev/.codex') + agentSessionAccountHome({ accountHomeVariable: 'CODEX_HOME' }, '/home/dev/.codex') ) ).toBe('{"variable":"CODEX_HOME","path":"/home/dev/.codex"}') }) @@ -37,10 +33,7 @@ describe('agent session account home', () => { it('refuses registering one agent twice', () => { expect(() => - agentSessionStoredAgents([ - ...CLAUDE_AND_CODEX_STORED_AGENTS.values(), - { agent: 'codex', handleTransport: 'acp', accountHomeVariable: 'CODEX_HOME' } - ]) + agentSessionStoredAgents([{ agent: 'claude' }, { agent: 'codex' }, { agent: 'codex' }]) ).toThrow('registered twice') }) }) diff --git a/src/shared/agent-session-provider-handle-encoding.ts b/src/shared/agent-session-provider-handle-encoding.ts index cbaf87853544..89d5a496fbe1 100644 --- a/src/shared/agent-session-provider-handle-encoding.ts +++ b/src/shared/agent-session-provider-handle-encoding.ts @@ -49,9 +49,10 @@ export function isAgentSessionProviderHandleInNamespace( /** * Whether a handle belongs to a record of this agent. Compares namespaces; never reads its data. - * Claude and Codex handles are pinned to their typed lane's transport. Another agent's transport is - * its registered definition's, which the record store checks on every row it loads or writes; a - * chain never changes transport after its first link. + * Claude and Codex handles are pinned to their typed lane's transport. Another agent's handle may be + * in any transport: the record store asks only that a chain keep one namespace owned by the + * record's agent, and whether this build speaks that transport is asked when the agent would start + * (`agentDrivesSession`). */ export function agentSessionProviderHandleBelongsTo( handle: AgentSessionProviderHandle, diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 590658b9635e..6750ec306454 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -227,7 +227,7 @@ export function isAgentSessionProcessIdentity( const ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/ /** Shape only: whether the variable is the one the record's agent pins is a launch-time question - * (`agentDrivesRecord`), so an agent that renames its variable never hides its chats. */ + * (`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 diff --git a/src/shared/agent-session-stored-agent.test-fixture.ts b/src/shared/agent-session-stored-agent.test-fixture.ts index de441fff4ad1..ca3d41f567cb 100644 --- a/src/shared/agent-session-stored-agent.test-fixture.ts +++ b/src/shared/agent-session-stored-agent.test-fixture.ts @@ -1,19 +1,7 @@ -import { - CLAUDE_STRUCTURED_HANDLE_NAMESPACE, - CODEX_STRUCTURED_HANDLE_NAMESPACE -} from './agent-session-provider-handle-encoding' import { agentSessionStoredAgents } from './agent-session-stored-agent' -/** What the shipped Claude and Codex definitions declare; a runtime test pins them equal. */ +/** The agents every older build's record store admitted; a runtime test pins the shipped list to it. */ export const CLAUDE_AND_CODEX_STORED_AGENTS = agentSessionStoredAgents([ - { - agent: 'claude', - handleTransport: CLAUDE_STRUCTURED_HANDLE_NAMESPACE.transport, - accountHomeVariable: 'CLAUDE_CONFIG_DIR' - }, - { - agent: 'codex', - handleTransport: CODEX_STRUCTURED_HANDLE_NAMESPACE.transport, - accountHomeVariable: 'CODEX_HOME' - } + { agent: 'claude' }, + { agent: 'codex' } ]) diff --git a/src/shared/agent-session-stored-agent.test.ts b/src/shared/agent-session-stored-agent.test.ts index 77fa3f84c863..611efb5540cd 100644 --- a/src/shared/agent-session-stored-agent.test.ts +++ b/src/shared/agent-session-stored-agent.test.ts @@ -9,7 +9,7 @@ import { agentSessionStoredAgents } from './agent-session-stored-agent' import { CLAUDE_AND_CODEX_STORED_AGENTS } from './agent-session-stored-agent.test-fixture' const GROK = { agent: 'grok', handleTransport: 'acp', accountHomeVariable: 'GROK_HOME' } -const WITH_GROK = agentSessionStoredAgents([...CLAUDE_AND_CODEX_STORED_AGENTS.values(), GROK]) +const WITH_GROK = agentSessionStoredAgents([{ agent: 'claude' }, { agent: 'codex' }, GROK]) function grokRecord(transport = 'acp'): AgentSessionRecord { const record = agentSessionRecordFixture() diff --git a/src/shared/agent-session-stored-agent.ts b/src/shared/agent-session-stored-agent.ts index 79c90f245e79..f27ded49e8c0 100644 --- a/src/shared/agent-session-stored-agent.ts +++ b/src/shared/agent-session-stored-agent.ts @@ -1,13 +1,17 @@ /** - * What a record of one structured agent may store, as the agent's definition declares it. + * Which structured agents' records a host admits, and what an agent's definition says its records + * pin. * - * A host admits records only of agents it registered: the runtime hands its registered agents to - * the record store, and a row of any other agent is set aside unread, never rewritten. Nothing here - * names an agent; the list is the runtime's. + * A host admits records only of agents it registered: the runtime hands the record store its + * registered agents' ids, and a row of any other agent is set aside unread, never rewritten. + * Whether a readable record's handles and account variable are the ones its agent's definition + * declares is not the store's question; it is asked only when that agent would start + * (`agentDrivesSession`). Nothing here names an agent; the list is the runtime's. */ import type { AgentSessionProviderTransport } from './agent-session-provider-handle' +/** What an agent's definition says its records pin. */ export type AgentSessionStoredAgent = { /** The Orca agent a record names as its `provider`. */ agent: string @@ -17,18 +21,18 @@ export type AgentSessionStoredAgent = { accountHomeVariable: string } -/** The agents a record store admits, keyed by agent. */ -export type AgentSessionStoredAgents = ReadonlyMap +/** The agents a record store admits: the ids this runtime registered. */ +export type AgentSessionStoredAgents = ReadonlySet export function agentSessionStoredAgents( - agents: readonly AgentSessionStoredAgent[] + agents: readonly Pick[] ): AgentSessionStoredAgents { - const byAgent = new Map() - for (const { agent, handleTransport, accountHomeVariable } of agents) { - if (byAgent.has(agent)) { + const admitted = new Set() + for (const { agent } of agents) { + if (admitted.has(agent)) { throw new Error(`structured agent ${agent} is registered twice`) } - byAgent.set(agent, { agent, handleTransport, accountHomeVariable }) + admitted.add(agent) } - return byAgent + return admitted } From 364f11fea9cd7928a9e972bc8824dfc99ab2d77b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:40:45 -0700 Subject: [PATCH 159/269] docs(agent-session): the record store admits the registered agents' ids --- src/main/runtime/structured-agent-runtime-registrations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index eba577611b3e..69a54d4c9300 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -194,7 +194,7 @@ export function structuredAgentRuntimeRegistration( ) } -/** What the record store admits: exactly the registered agents' declared storage. */ +/** What the record store admits: the ids of exactly the agents registered here. */ export const STRUCTURED_AGENT_STORAGE: AgentSessionStoredAgents = agentSessionStoredAgents( STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map((registration) => registration.definition) ) From 57a7a29f49dbe0980e4aa5f7d3f79c29be094e5c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:15:15 -0700 Subject: [PATCH 160/269] refactor(native-chat): Grok's registration declares where it runs; ACP no longer borrows Codex's location rule The rule a self-supervised agent child runs under (this machine, no WSL, Windows only with process start-time proof) is its own module that Codex and the ACP adapter both use. Grok's registration takes the full account-home resolver signature, and D3's tests build hosts with the agent registry. --- .../acp/acp-structured-host-lifecycle.test.ts | 5 ++ .../acp/acp-structured-session-adapter.ts | 4 +- .../codex-structured-location-support.ts | 8 +--- ...agent-session-close-abandons-start.test.ts | 2 + .../supervised-provider-child-location.ts | 16 +++++++ ...ed-agent-runtime-registrations-acp.test.ts | 47 ++++++++++++++----- .../structured-agent-runtime-registrations.ts | 3 +- ...tructured-agent-stored-definitions.test.ts | 11 +---- ...red-agent-registered-agent-surface.test.ts | 13 ++++- 9 files changed, 77 insertions(+), 32 deletions(-) create mode 100644 src/main/provider-process/supervised-provider-child-location.ts diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index 4dafb16f1cb7..5f5246d8cac1 100644 --- a/src/main/acp/acp-structured-host-lifecycle.test.ts +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -12,6 +12,7 @@ import { messageText } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' import { CALLER, envelope, @@ -34,6 +35,7 @@ import { replyChunk, waitFor } from './acp-structured-adapter.test-support' +import { acpStructuredAgentDefinition } from './acp-structured-agent-definitions' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' @@ -91,6 +93,9 @@ async function openHostRig( } }) const host = new StructuredAgentSessionHost({ + agents: new StructuredAgentRegistry([ + { definition: acpStructuredAgentDefinition(GROK), adapter: rig.adapter } + ]), logger: state.log.logger, store, adapter: rig.adapter, diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index fc7e064b13d5..d5328b693edc 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -19,7 +19,7 @@ import { type StructuredAgentSessionAdapter, type StructuredAgentSessionSetOptionInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' +import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' import { withObservedProviderExit } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { acpAgentName, acquireAcpStructuredSession } from './acp-structured-acquire' import { @@ -43,7 +43,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte // The child runs on this runtime's own machine; Windows needs process start-time proof. supportsLocation = (location: AgentSessionExecutionLocation): boolean => - supportsCodexStructuredLocation(location, this.deps.isWindowsProcessStartTimeAvailable) + supportsSupervisedProviderChildLocation(location, this.deps.isWindowsProcessStartTimeAvailable) async acquire(input: StructuredAgentSessionAcquireInput): Promise { const sessionId = input.identity.sessionId diff --git a/src/main/codex/codex-structured-location-support.ts b/src/main/codex/codex-structured-location-support.ts index ad0bbefa4d33..bdd3147b8442 100644 --- a/src/main/codex/codex-structured-location-support.ts +++ b/src/main/codex/codex-structured-location-support.ts @@ -1,5 +1,5 @@ -import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' export function supportsCodexStructuredLocation( @@ -7,9 +7,5 @@ export function supportsCodexStructuredLocation( // Injected by the adapter, which owns this dep for every other Codex gate too. hasWindowsProcessStartTimeProof: () => boolean = isWindowsProcessStartTimeAvailable ): boolean { - return ( - location.executionHostId === LOCAL_EXECUTION_HOST_ID && - location.wslDistro === null && - (process.platform !== 'win32' || hasWindowsProcessStartTimeProof()) - ) + return supportsSupervisedProviderChildLocation(location, hasWindowsProcessStartTimeProof) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-abandons-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-abandons-start.test.ts index d67fd0b40e20..1f23110026f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-abandons-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-abandons-start.test.ts @@ -17,6 +17,7 @@ import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' it('a close stops a start the provider never answers instead of queueing behind it', async () => { const state = hostTestState() @@ -30,6 +31,7 @@ it('a close stops a start the provider never answers instead of queueing behind // The provider's child stops, so its unanswered handshake fails the start. const abandonStart = vi.fn(async () => failStart?.(new Error('closed while starting'))) const host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store: state.store, adapter: { ...adapter(), abandonStart }, diff --git a/src/main/provider-process/supervised-provider-child-location.ts b/src/main/provider-process/supervised-provider-child-location.ts new file mode 100644 index 000000000000..52a5f5b6c6df --- /dev/null +++ b/src/main/provider-process/supervised-provider-child-location.ts @@ -0,0 +1,16 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' + +/** Where an agent child this runtime spawns and supervises itself can run: on this machine, outside + * WSL, and on Windows only where a process start time proves which process a record names. */ +export function supportsSupervisedProviderChildLocation( + location: AgentSessionExecutionLocation, + hasWindowsProcessStartTimeProof: () => boolean = isWindowsProcessStartTimeAvailable +): boolean { + return ( + location.executionHostId === LOCAL_EXECUTION_HOST_ID && + location.wslDistro === null && + (process.platform !== 'win32' || hasWindowsProcessStartTimeProof()) + ) +} diff --git a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts index 92ca538ee236..4b016f11670a 100644 --- a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts +++ b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, STRUCTURED_AGENT_STORAGE, - structuredAgentRuntimeRegistration + structuredAgentRuntimeRegistration, + type StructuredAgentAccountHomeServices } from './structured-agent-runtime-registrations' describe('ACP agents in the runtime registrations', () => { @@ -25,12 +26,8 @@ describe('ACP agents in the runtime registrations', () => { }) }) - it('admits Grok records under the protocol transport and its config home variable', () => { - expect(STRUCTURED_AGENT_STORAGE.get('grok')).toEqual({ - agent: 'grok', - handleTransport: 'acp', - accountHomeVariable: 'GROK_HOME' - }) + it("admits Grok's records", () => { + expect(STRUCTURED_AGENT_STORAGE.has('grok')).toBe(true) }) it('takes model and effort picks at rest, and keeps no model list of its own', () => { @@ -40,10 +37,36 @@ describe('ACP agents in the runtime registrations', () => { expect(resting.fallbackModels()).toBeNull() }) - it('finds the account home on this runtime from the launch env, else the default', () => { - const resolve = structuredAgentRuntimeRegistration('grok')!.resolveAccountHomePath! - expect(resolve({ launchEnv: { GROK_HOME: '/data/grok' } })).toBe('/data/grok') - expect(resolve({ launchEnv: { GROK_HOME: 'relative/grok' } })).toMatch(/\.grok$/) - expect(structuredAgentRuntimeRegistration('claude')?.resolveAccountHomePath).toBeUndefined() + it('finds the account home on this runtime from the launch env, else the default', async () => { + const { resolveAccountHomePath } = structuredAgentRuntimeRegistration('grok')! + // Grok's resolver asks the runtime for nothing: its home is the launch env's, else the default. + const unused = (): never => { + throw new Error('Grok resolves its account home without the runtime') + } + const services: StructuredAgentAccountHomeServices = { + getClaudeConfigDirectory: unused, + prepareCodexLaunchHome: unused, + readCodexLaunchHome: unused, + workspaceTrustSettings: unused + } + const resolve = (launchEnv: NodeJS.ProcessEnv) => + resolveAccountHomePath( + { launchEnv, location: null, purpose: 'read', workspacePath: null }, + services + ) + await expect(resolve({ GROK_HOME: '/data/grok' })).resolves.toBe('/data/grok') + await expect(resolve({ GROK_HOME: 'relative/grok' })).resolves.toMatch(/\.grok$/) + }) + + it('runs Grok only where this runtime supervises the child itself', () => { + const { supportsLocation } = structuredAgentRuntimeRegistration('grok')! + const local = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + } as const + expect(supportsLocation({ ...local, wslDistro: 'Ubuntu' })).toBe(false) + expect(supportsLocation({ ...local, executionHostId: 'ssh:box' })).toBe(false) }) }) diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 910c83229adc..d9b91c0aa287 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -7,6 +7,7 @@ import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution' import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' import { supportsClaudeStructuredLocation } from '../claude/claude-structured-location-support' +import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' import { applyStructuredCodexWorkspaceTrust } from '../agent-workspace-trust-spawn' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' @@ -156,7 +157,7 @@ function createClaudeAdapter( function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistration { return { definition: acpStructuredAgentDefinition(spec), - supportsLocation: (location) => supportsCodexStructuredLocation(location), + supportsLocation: (location) => supportsSupervisedProviderChildLocation(location), resolveAccountHomePath: async ({ launchEnv }) => resolveStructuredEnvAccountHomePath({ launchEnv, diff --git a/src/main/runtime/structured-agent-stored-definitions.test.ts b/src/main/runtime/structured-agent-stored-definitions.test.ts index f94cc0d70713..6186e631a704 100644 --- a/src/main/runtime/structured-agent-stored-definitions.test.ts +++ b/src/main/runtime/structured-agent-stored-definitions.test.ts @@ -27,14 +27,7 @@ describe('what the shipped definitions let a record store', () => { expect([...STRUCTURED_AGENT_STORAGE.keys()]).toEqual( STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) ) - // Claude and Codex keep the storage every older build wrote; Grok's is new and its own. - expect(new Map([...STRUCTURED_AGENT_STORAGE].filter(([agent]) => agent !== 'grok'))).toEqual( - CLAUDE_AND_CODEX_STORED_AGENTS - ) - expect(STRUCTURED_AGENT_STORAGE.get('grok')).toEqual({ - agent: 'grok', - handleTransport: 'acp', - accountHomeVariable: 'GROK_HOME' - }) + // Claude and Codex are admitted as every older build admitted them; Grok beside them. + expect(STRUCTURED_AGENT_STORAGE).toEqual(new Set([...CLAUDE_AND_CODEX_STORED_AGENTS, 'grok'])) }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts b/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts index be865276d2c9..a44c2fa68c1c 100644 --- a/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' -import { NO_AGENT_SESSION_CAPABILITIES } from '../../../../shared/agent-session-capabilities' +import type { AgentSessionCapabilities } from '../../../../shared/agent-session-capabilities' import type { Tab } from '../../../../shared/tab-types' import { parseWorkspaceSession } from '../../../../shared/workspace-session-schema' @@ -54,9 +54,18 @@ describe('a host-registered agent on the structured chat surface', () => { }) it("attaches images only when the host's record for the agent takes them", () => { + const capabilities = (imagePrompts: boolean): AgentSessionCapabilities => ({ + rewind: false, + compact: false, + threadGoal: false, + contextUsage: false, + imagePrompts, + steering: 'queue', + approvalEnforcement: 'orca' + }) const record = (imagePrompts: boolean) => ({ agent: 'grok', - capabilities: { ...NO_AGENT_SESSION_CAPABILITIES, imagePrompts } + capabilities: capabilities(imagePrompts) }) expect(structuredAgentAcceptsImages(record(false), 'grok')).toBe(false) expect(structuredAgentAcceptsImages(record(true), 'grok')).toBe(true) From 0fc5a7279098551dfa0369ab57c99f2e2ad3545d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:19:00 -0700 Subject: [PATCH 161/269] fix(native-chat): Grok follows the ACP runtime's request contract and the managed process's close A request the agent or a Stop cancels is answered with the agent's own cancelled reply by the code that owns it (the runtime no longer answers a silent handler), so a Stop needs no separate decline pass. A permission answer still being saved when the agent stopped waiting is reported unconfirmed, since the protocol already answered it cancelled. Cancelling the agent's own turn is the plain cancel. Request rows are matched under their generation-scoped ids. A refusal's reason comes from the dialect's wording path. The child drops its own stderr tail and close policy for the managed process's, and a close whose process tree was not proven gone is reported as the adapter contract asks. --- .../acp-structured-adapter.test-support.ts | 1 + src/main/acp/acp-structured-child.ts | 30 +++------ src/main/acp/acp-structured-lane.ts | 26 +++---- src/main/acp/acp-structured-prompts.ts | 34 ++++++---- .../acp-structured-session-adapter.test.ts | 67 ++++++++++++++++++- .../acp/acp-structured-session-adapter.ts | 32 ++++++--- src/main/acp/acp-structured-turns.ts | 5 +- src/main/acp/acp-timeline-translator.ts | 10 +-- 8 files changed, 144 insertions(+), 61 deletions(-) diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index 1d157f6896ad..6c277e32bddc 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -51,6 +51,7 @@ export class FakeAcpChild implements AcpStructuredChild { readonly pid: number | undefined = PID readonly spawned = Promise.resolve() stderr = '' + treeUnproven = false private listeners: (() => void)[] = [] private gone = false closes = 0 diff --git a/src/main/acp/acp-structured-child.ts b/src/main/acp/acp-structured-child.ts index f41437bcbaa6..b125a9404e1f 100644 --- a/src/main/acp/acp-structured-child.ts +++ b/src/main/acp/acp-structured-child.ts @@ -6,11 +6,6 @@ import type { Readable, Writable } from 'node:stream' import { spawnProcess } from '../../shared/child-process/run-process' import { spawnManagedProviderProcess } from '../provider-process/managed-provider-process' import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' -import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' - -const GRACEFUL_EXIT_MS = 1_500 -const FORCED_EXIT_MS = 1_000 -const STDERR_TAIL_MAX_CHARS = 8_192 export type AcpStructuredChild = { readonly pid: number | undefined @@ -26,6 +21,8 @@ export type AcpStructuredChild = { stderrTail(): string /** True only once the exit is proven. */ close(): Promise + /** The root exited but the close could not prove its process tree gone. */ + readonly treeUnproven: boolean } export type SpawnAcpStructuredChild = (launch: ProviderProcessLaunch) => AcpStructuredChild @@ -34,20 +31,9 @@ export function spawnAcpStructuredChild( launch: ProviderProcessLaunch, spawnImpl: typeof spawnProcess = spawnProcess ): AcpStructuredChild { - const managed = spawnManagedProviderProcess(launch, { - spawnImpl, - site: 'acp-agent-teardown', - acceptClose: (result) => result.root === 'exited', - policy: (supervised) => ({ - gracefulExitMs: supervised ? PROVIDER_SUPERVISOR_MAX_STOP_MS : GRACEFUL_EXIT_MS, - forcedExitMs: FORCED_EXIT_MS - }) - }) + // The managed process owns the close ladder, its root-only defaults, and the stderr tail. + const managed = spawnManagedProviderProcess(launch, { spawnImpl, site: 'acp-agent-teardown' }) const { child } = managed - let stderr = '' - child.stderr.setEncoding('utf8').on('data', (chunk: string) => { - stderr = (stderr + chunk).slice(-STDERR_TAIL_MAX_CHARS) - }) // A broken pipe surfaces as the exit the managed process observes; never as an uncaught error. child.stdin.on('error', () => {}) const spawned = new Promise((resolve) => { @@ -69,7 +55,11 @@ export function spawnAcpStructuredChild( }, spawned, onExit: (listener) => managed.onExit(() => listener()), - stderrTail: () => stderr.trim(), - close: async () => managed.rootVerdict === 'exited' || (await managed.close()).root === 'exited' + stderrTail: () => managed.stderrTail().trim(), + close: async () => (await managed.close()).root === 'exited', + get treeUnproven() { + const result = managed.lastCloseResult + return result?.root === 'exited' && result.tree === 'unverifiable' + } } } diff --git a/src/main/acp/acp-structured-lane.ts b/src/main/acp/acp-structured-lane.ts index 2ded3d1beec1..d73f6effa6c2 100644 --- a/src/main/acp/acp-structured-lane.ts +++ b/src/main/acp/acp-structured-lane.ts @@ -10,7 +10,7 @@ import { type ProviderTimelineAssembler } from '../native-chat/agent-session-timeline/provider-timeline-assembler' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' -import { spellProviderTimelineKey } from '../native-chat/agent-session-timeline/provider-timeline-identity' +import { createLegacyProviderTimelineIdentityScheme } from '../native-chat/agent-session-timeline/provider-timeline-identity' import type { ProviderTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' import type { AcpDialect } from './acp-dialects/acp-dialect' import { AcpTimelineTranslator } from './acp-timeline-translator' @@ -49,6 +49,7 @@ export class AcpStructuredLane { private readonly assembler: ProviderTimelineAssembler private readonly backlog: ProviderTimelineEvent[] = [] private readonly turnWatchers = new Set<() => void>() + private readonly requestIdentity: ReturnType private retryTimer: ReturnType | null = null private failed = false private disposed = false @@ -59,6 +60,10 @@ export class AcpStructuredLane { journalItems: () => journalRenderItems(deps.sink.journalItems()), dialect: deps.dialect }) + this.requestIdentity = createLegacyProviderTimelineIdentityScheme({ + agent: deps.agent, + sessionId: deps.sessionId + }) this.assembler = createProviderTimelineAssembler({ sink: deps.sink, sessionId: deps.sessionId, @@ -99,22 +104,19 @@ export class AcpStructuredLane { this.drain() } - /** The legacy-scheme record a request row of `requestKey` is written under, before any - * incarnation suffix a reused key adds. */ - requestRecordPrefix(requestKey: string): string { - return `request:${spellProviderTimelineKey(this.deps.providerSessionId, { - source: 'provider', - value: requestKey - })}` - } - - /** Whether the journal row `itemId` is the row of request `requestKey`. */ + /** Whether the journal row `itemId` is the row of request `requestKey`, which the assembler + * writes under this acquisition's generation (any incarnation a reused key adds). */ isRequestRow(itemId: string, requestKey: string): boolean { const identity = parseAgentJournalItemKey(itemId) if (identity?.provider !== 'legacy' || identity.sessionId !== this.deps.sessionId) { return false } - const prefix = this.requestRecordPrefix(requestKey) + const first = this.requestIdentity.request({ + generation: this.deps.generation, + key: requestKey, + incarnation: 1 + }) + const prefix = first.provider === 'legacy' ? first.recordId : null return identity.recordId === prefix || identity.recordId.startsWith(`${prefix}#`) } diff --git a/src/main/acp/acp-structured-prompts.ts b/src/main/acp/acp-structured-prompts.ts index 5b97aab1ffe8..2157a606f75b 100644 --- a/src/main/acp/acp-structured-prompts.ts +++ b/src/main/acp/acp-structured-prompts.ts @@ -2,6 +2,9 @@ // JSON-RPC call it is until a person answers it, a Stop cancels it, or the agent withdraws it. // An answer claims the request, commits the journal compare-and-set while the claim is held, and // only then replies, so a second client loses the commit and the agent hears exactly one answer. +// Each request is this module's to answer: once the agent or a Stop cancels it, an unclaimed one is +// answered with the agent's own cancelled reply, and a claimed one still sends the committed answer +// — except a permission, which the protocol answers `cancelled` itself the moment it is cancelled. import type { AgentSessionPromptResponse } from '../../shared/agent-session-question-answer' import { @@ -16,6 +19,9 @@ import type { AcpStructuredLane } from './acp-structured-lane' type OpenRequest = { key: string presentation: AcpRequestPresentation + /** A permission, whose cancellation the protocol answers itself. */ + permission: boolean + signal: AbortSignal claimed: boolean settle: (reply: unknown) => void } @@ -47,6 +53,8 @@ export class AcpStructuredPrompts { const entry: OpenRequest = { key, presentation, + permission: method === 'session/request_permission', + signal: context.signal, claimed: false, settle: (reply) => { if (this.open.get(key) === entry) { @@ -56,13 +64,13 @@ export class AcpStructuredPrompts { } } this.open.set(key, entry) - // The agent or a Stop gave up on it: its card can no longer be answered. + // The agent or a Stop gave up on it: its card can no longer be answered, and the agent hears + // its own cancelled reply. A claimed one finishes its answer. context.signal.addEventListener( 'abort', () => { if (this.open.get(key) === entry && !entry.claimed) { - this.open.delete(key) - this.lane()?.apply([{ type: 'request.withdrawn', request: key }]) + this.withdraw(entry) } }, { once: true } @@ -92,21 +100,21 @@ export class AcpStructuredPrompts { await input.commit() } catch (error) { entry.claimed = false + if (entry.signal.aborted) { + this.withdraw(entry) + } throw error } entry.settle(reply) + if (entry.permission && entry.signal.aborted) { + // Cancelled while the answer was saved: the agent already heard `cancelled`, not this answer. + throw new Error(`the agent stopped waiting for the answer to ${input.itemId}`) + } } - /** Declines every unclaimed request the way the agent's protocol spells a cancellation, and - * closes its card. */ - cancelAll(): void { - for (const entry of Array.from(this.open.values())) { - if (entry.claimed) { - continue - } - entry.settle(entry.presentation.reply(null)) - this.lane()?.apply([{ type: 'request.withdrawn', request: entry.key }]) - } + private withdraw(entry: OpenRequest): void { + entry.settle(entry.presentation.reply(null)) + this.lane()?.apply([{ type: 'request.withdrawn', request: entry.key }]) } /** The child is gone: nobody can answer for it any more. */ diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index a9faf1acb1bf..685d844684e9 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -6,7 +6,8 @@ import { } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { AgentSessionAcquisitionExitProvenError, - AgentSessionAcquisitionRefusal + AgentSessionAcquisitionRefusal, + AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { ACP_CHILD_ENV_TO_DELETE } from './acp-launch-specs' import { @@ -241,6 +242,60 @@ describe('ACP structured session adapter: approvals', () => { }) }) +describe('ACP structured session adapter: requests a Stop cancels', () => { + it("answers an open plan approval with Grok's own cancelled reply once a Stop cancels it", async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + const prompt = await rig.frame('session/prompt') + const { agent } = rig.child() + const plan = agent.request(5, '_x.ai/exit_plan_mode', { + sessionId: PROVIDER_SESSION, + toolCallId: 'call-2', + planContent: '# Plan' + }) + await waitFor(async () => { + expect((await rig.rig.rows()).some((row) => row.body.kind === 'approval')).toBe(true) + }) + agent.on('session/cancel', () => agent.reply(prompt, { stopReason: 'cancelled' })) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + expect(await plan).toMatchObject({ result: { outcome: 'abandoned' } }) + expect(agent.frames.filter((frame) => frame.id === 5)).toHaveLength(1) + }) + + it('reports a permission answer whose save outlived the Stop as unconfirmed, not given', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + const prompt = await rig.frame('session/prompt') + const { agent } = rig.child() + const permission = agent.request(6, 'session/request_permission', { + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId: 'call-1', title: 'Write file' }, + options: [{ optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }] + }) + const row = await waitFor(async () => { + const found = (await rig.rig.rows()).find((item) => item.body.kind === 'approval') + expect(found).toBeDefined() + return found! + }) + agent.on('session/cancel', () => agent.reply(prompt, { stopReason: 'cancelled' })) + const answering = rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: row.itemId, + kind: 'approval', + response: { kind: 'option', optionId: 'allow-once' }, + fence: 1, + // The Stop lands while the person's answer is being saved. + commit: () => rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 }).then(() => {}) + }) + await expect(answering).rejects.toThrow(/stopped waiting/) + expect(await permission).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + }) +}) + describe('ACP structured session adapter: options and commands', () => { it('reads the agent own models and effort and writes a pick through set_config_option', async () => { const rig = await openAcpAdapterRig({ @@ -286,6 +341,16 @@ describe('ACP structured session adapter: options and commands', () => { }) describe('ACP structured session adapter: close and exit', () => { + it('reports a close whose root exited but whose process tree was not proven gone', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + rig.child().treeUnproven = true + await expect(rig.adapter.closeSession(SESSION)).rejects.toBeInstanceOf( + AgentSessionAcquisitionRootExitObservedError + ) + expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + }) + it('closes with proof and reports a requested close', async () => { const rig = await openAcpAdapterRig() await rig.acquire() diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index d5328b693edc..bb15da2d8121 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -9,6 +9,7 @@ import type { AgentJournalMessageItem } from '../../shared/agent-session-journal import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { AgentSessionAcquisitionExitProvenError, + AgentSessionAcquisitionRootExitObservedError, AgentSessionAcquisitionExitUnprovenError, AgentSessionPreSpawnError, isAgentSessionPreSpawnError, @@ -160,11 +161,11 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return { cancelled: false, refusal: { turnNotRunning: true } } } const withdrew = session.turns.withdrawQueued() - session.prompts.cancelAll() if (session.turns.running) { try { - // Ends when the agent answers the prompt `cancelled`; open permissions are declined first. - // Past the bound the connection closes, which stops the child. + // Ends when the agent answers the prompt `cancelled`. The cancel aborts every open request + // first, each answered as the agent spells a cancellation. Past the bound the connection + // closes, which stops the child. await session.runtime.cancel() return { cancelled: true } } catch { @@ -190,7 +191,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte }) try { const left = session.lane.whenTurnLeaves(turnId).then(() => true) - await session.runtime.cancel({ agentTurn: true }) + await session.runtime.cancel() if ((await Promise.race([left, deadline])) && session.journalClosed === null) { return { cancelled: true } } @@ -244,21 +245,36 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte ) => this.sessions.has(sessionId) ? { supportsTaskStop: false, supportsStopAll: false } : undefined - closeSession = (sessionId: string): Promise => this.stop(sessionId, true) - disposeSession = (sessionId: string): Promise => this.stop(sessionId, true) + closeSession = (sessionId: string): Promise => this.close(sessionId) + disposeSession = (sessionId: string): Promise => this.close(sessionId) releaseAcquisition = (input: { sessionId: string }): Promise => - this.stop(input.sessionId, true) + this.close(input.sessionId) /** After a sink failure: the exit is recovered as unexpected. */ forceCloseSession = (sessionId: string): Promise => this.stop(sessionId, false) async closeAll(): Promise { const ids = new Set([...this.sessions.keys(), ...this.starts.sessionIds()]) - const proven = await Promise.all([...ids].map((sessionId) => this.closeSession(sessionId))) + const proven = await Promise.all([...ids].map((sessionId) => this.stop(sessionId, true))) if (proven.includes(false)) { throw new Error('an ACP agent child could not be proven stopped') } } + /** A requested close. The root's exit proves it; a process tree the close could not prove gone is + * reported to the caller that owns the child's record, as the adapter contract asks. */ + private async close(sessionId: string): Promise { + const child = this.sessions.get(sessionId)?.child + const closed = await this.stop(sessionId, true) + if (closed && child?.treeUnproven) { + throw new AgentSessionAcquisitionRootExitObservedError( + new Error( + `${this.deps.spec.agent} ACP agent exited, but its process tree was not proven gone` + ) + ) + } + return closed + } + /** True only once every child is proven gone, or when this adapter runs none for the session. */ private async stop(sessionId: string, requested: boolean): Promise { const [abandoned, previous] = await Promise.all([ diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index 4d5525432277..5e8b1c6e7aa0 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -125,9 +125,10 @@ export class AcpStructuredTurns { this.deps.onTransportFault(error) return } - if (lane.translator.promptRefused(send.clientMessageId)) { + const refusal = lane.translator.promptRefused(send.clientMessageId, error) + if (refusal !== null) { // The agent answered the prompt with an error before starting it: its own refusal. - const detail = providerDiagnostic(error.message, 'person') + const detail = providerDiagnostic(refusal, 'person') this.reject( send.clientMessageId, agentSessionFailureFact('providerRejected', detail ? { detail } : {}) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 0d253bccf13d..333a8a34acf9 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -99,15 +99,15 @@ export class AcpTimelineTranslator { return this.finishPrompt(clientMessageId, 'error', at, detail) } - /** The agent refused the prompt before its turn began: forgets it and answers true. False once - * the turn opened, when the refusal ends that turn instead (`promptFailed`). */ - promptRefused(clientMessageId: string): boolean { + /** The agent refused the prompt before its turn began: forgets it and answers the agent's reason. + * Null once the turn opened, when the refusal ends that turn instead (`promptFailed`). */ + promptRefused(clientMessageId: string, error: AcpAgentError): string | null { const prompt = this.prompts.current if (prompt?.clientMessageId !== clientMessageId || prompt.opened) { - return false + return null } this.prompts.current = undefined - return true + return acpPromptErrorDetail(this.dialect, error) } private finishPrompt( From 1108280d1d9cb513ff92380da9db981a9c9e3fff Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:25:50 -0700 Subject: [PATCH 162/269] fix(native-chat): a Grok chat Orca already holds resumes without writing what Grok replays A chat with a saved Grok session reattaches with session/resume where the agent offers it, else session/load. Either way the call runs inside the translator's load window, so what Grok sends while it reattaches (its saved exchange, a task the dead process left running, ended by the restart) opens no turn and writes no row; only context usage reads on. A reply an Orca or Grok crash cut short is no longer completed from Grok's saved history: it reads like a Claude or Codex chat's, with the existing notice. The attach window also closes after a failed attach, and a created session that session/resume reports missing is replaced like one session/load reports missing. The replay reconciliation is removed: the lane no longer reads the journal, and D3's replayed-input grammar test and completed-turn check in the assembler go with it. --- src/main/acp/acp-structured-acquire.ts | 38 ++-- .../acp/acp-structured-host-lifecycle.test.ts | 71 ++++-- src/main/acp/acp-structured-lane.ts | 21 +- ...-structured-session-adapter-resume.test.ts | 206 ++++++++++-------- ...-timeline-assembler-replayed-input.test.ts | 142 ------------ .../provider-timeline-decision.ts | 16 -- 6 files changed, 198 insertions(+), 296 deletions(-) delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replayed-input.test.ts diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index b67da9caa56e..899f02f59f24 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -1,7 +1,9 @@ // Making a reservation real for an ACP agent: spawn the child, record it before any handshake, -// initialize with the client's file system and terminals off, then load the session this chat -// proved or start a new one. The handshake is bounded: an agent that never answers fails the start -// instead of holding the chat's queue, and a close can stop the child at any point of it. +// initialize with the client's file system and terminals off, then reattach the session this chat +// proved (`session/resume` where the agent offers it, else `session/load`) or start a new one. The +// journal already holds a reattached chat, so whatever the agent sends while it reattaches is not +// written, except context usage. The handshake is bounded: an agent that never answers fails the +// start instead of holding the chat's queue, and a close can stop the child at any point of it. import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' @@ -39,8 +41,8 @@ import { RequestPermissionResponseSchema } from './generated/acp-protocol.genera const ACP_RESOURCE_NOT_FOUND = -32002 /** Frames an agent may send before its session exists; past this the start is refused. */ const MAX_EARLY_FRAMES = 2_048 -/** How long the agent has to answer the handshake, load or create its session, and take saved - * options back. A load replays the whole conversation, so this is generous. */ +/** How long the agent has to answer the handshake, reattach or create its session, and take saved + * options back. A load-only agent replays the whole conversation, so this is generous. */ export const ACP_STARTUP_TIMEOUT_MS = 60_000 export class AcpStartupTimeoutError extends Error { @@ -160,11 +162,13 @@ export async function acquireAcpStructuredSession(input: { `${spec.agent} ACP agent`, deps.readProcessStartTime ) - const makeLane = (providerSessionId: string): AcpStructuredLane => { + /** `attaching`: the lane opens inside the attach window, before any frame queued so far. */ + const makeLane = (providerSessionId: string, attaching = false): AcpStructuredLane => { const lane = new AcpStructuredLane({ sink, sessionId, agent: spec.agent, + agentName: acpAgentName(spec.agent), generation, providerSessionId, dialect: spec.dialect, @@ -172,6 +176,9 @@ export async function acquireAcpStructuredSession(input: { onFailed: () => input.forceClose(sessionId) }) slot.lane = lane + if (attaching) { + lane.translator.beginLoad() + } for (const deliver of early.splice(0)) { deliver() } @@ -198,18 +205,17 @@ export async function acquireAcpStructuredSession(input: { let liveLane: AcpStructuredLane | null = null let supersedesKey: string | undefined if (resume) { - liveLane = makeLane(resume.sessionId) - // What the agent replays before answering the load is reconciled against the journal, which - // stays the truth: turns it settled are skipped, and what the agent saved that Orca never - // wrote (a reply cut off by a crash) is adopted. - liveLane.translator.beginLoad() + // Adoption would plug in here: a chat whose journal holds none of the agent's history would + // load with the translator adopting the replay. No Grok chat reaches that state today. + const attaching = makeLane(resume.sessionId, true) + liveLane = attaching try { started = await runtime.start({ cwd: launch.cwd, mcpServers: [], - sessionId: resume.sessionId + sessionId: resume.sessionId, + resumePreference: 'resume' }) - liveLane.apply(liveLane.translator.finishLoad(now())) } catch (error) { const notFound = error instanceof AcpRpcError && error.code === ACP_RESOURCE_NOT_FOUND if (!notFound || resume.replaceableKey === null) { @@ -217,6 +223,12 @@ export async function acquireAcpStructuredSession(input: { } // A session this chat created and the agent never saved: a new one takes its place. supersedesKey = resume.replaceableKey + } finally { + // Also after a failed attach, so the translator takes prompts again. + const usage = attaching.translator.finishLoad(now()) + if (started) { + attaching.apply(usage) + } } } if (!started || !liveLane) { diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index 5f5246d8cac1..ba1bf59832e9 100644 --- a/src/main/acp/acp-structured-host-lifecycle.test.ts +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -6,6 +6,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import { agentSessionStoredAgents } from '../../shared/agent-session-stored-agent' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { withNativeChatCutTurnNotices } from '../../shared/native-chat-cut-turn-notice' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { closeProviderTimelineRigs, @@ -73,6 +75,7 @@ function launch(resume: () => boolean): () => Promise { async function openHostRig( options: { script?: (agent: AcpScriptedAgent) => void + initialize?: Record deps?: Partial } = {} ) { @@ -83,9 +86,12 @@ async function openHostRig( ]) }) let generation = 0 + // As the runtime wires it: every exit the adapter observes reaches the host. + const hosted: { host: StructuredAgentSessionHost | null } = { host: null } const rig = await openAcpAdapterRig({ ...options, deps: { + onEvent: (event) => void hosted.host?.handleAdapterEvent(event), now: () => HOST_TEST_NOW, readProcessStartTime: async () => 1_700_000_000_000 + ++generation, mintGeneration: () => `generation-${generation}`, @@ -105,6 +111,7 @@ async function openHostRig( mintSpawnToken: () => 'spawn-a', now: () => HOST_TEST_NOW }) + hosted.host = host replaceHostTestState({ store, host }) const fence = () => store.getRecord(SESSION)?.lease.runtimeFence ?? 1 const messages = async () => @@ -135,59 +142,85 @@ async function openHostRig( return { rig, host, store, fence, messages, exchange } } -/** On every load, Grok replays the exchange it saved: the user's `hello`, then `reply`. */ -function replays(reply: string, loads: { count: number }) { +/** Grok's capabilities: it loads and resumes sessions. */ +const RESUMES = { agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } } } + +/** On every resume, Grok sends what its resume may carry: the saved `reply` marked as replay, and a + * task the dead process left running, ended by the restart. */ +function resumes(reply: string, count: { resumes: number }) { return (agent: AcpScriptedAgent) => - agent.on('session/load', (frame) => { - loads.count += 1 - agent.notify('session/update', { + agent.on('session/resume', (frame) => { + count.resumes += 1 + agent.notify('session/update', replyChunk('prompt:m1', reply, { isReplay: true })) + agent.notify('x.ai/task_completed', { sessionId: PROVIDER_SESSION, - update: { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'hello' } }, - _meta: { isReplay: true } + update: { + sessionUpdate: 'task_completed', + task_snapshot: { task_id: 'task-orphan', command: 'sleep 600', signal: 'session_restart' } + } }) - agent.notify('session/update', replyChunk('prompt:m1', reply, { isReplay: true })) agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) }) } describe('resuming a Grok chat through the host', () => { - it('writes the user message once when Grok replays an exchange the journal holds', async () => { - const loads = { count: 0 } + it('resumes a chat the journal holds and writes its exchange once', async () => { + const count = { resumes: 0 } let resumed = false const { host, fence, messages, exchange } = await openHostRig({ - script: replays('hi', loads), + initialize: RESUMES, + script: resumes('hi', count), deps: { resolveLaunch: launch(() => resumed) } }) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) resumed = true await exchange('hi', true) expect(await messages()).toEqual(['hello', 'hi']) + const before = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) await host.flushStreamedEvents(SESSION) - expect(loads.count).toBe(1) + expect(count.resumes).toBe(1) expect(await messages()).toEqual(['hello', 'hi']) + const after = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + expect(after.filter((row) => row.body.kind === 'background-task')).toEqual([]) + expect(after.filter((row) => readAgentJournalTurn(row.body))).toHaveLength( + before.filter((row) => readAgentJournalTurn(row.body)).length + ) await host.close(SESSION, 'user-close') }) - it('recovers the reply Grok saved past what an unfinished turn journaled, and keeps its end', async () => { + it('shows a reply a crash cut off with the existing notice, never completed from what Grok saved', async () => { let resumed = false - const { host, fence, messages, exchange } = await openHostRig({ - script: replays('complete saved reply', { count: 0 }), + const { rig, host, fence, messages, exchange } = await openHostRig({ + initialize: RESUMES, + script: resumes('complete saved reply', { resumes: 0 }), deps: { resolveLaunch: launch(() => resumed) } }) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) resumed = true await exchange('complete', false) - await host.close(SESSION, 'user-close') + // Grok dies mid-reply; the host ends its record, which moves the chat's fence. + const cutAt = fence() + rig.child().exit() + await waitFor(() => expect(fence()).toBeGreaterThan(cutAt)) expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) await host.flushStreamedEvents(SESSION) - expect(await messages()).toEqual(['hello', 'complete saved reply']) + expect(await messages()).toEqual(['hello', 'complete']) const rows = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items - // The replay recovers content; it never says how the turn ended. expect(rows.flatMap((row) => readAgentJournalTurn(row.body)?.state ?? [])).not.toContain( - 'running' + 'completed' ) + // The transcript explains the cut the way a Claude or Codex chat's does. + const transcript = withNativeChatCutTurnNotices(rows, { agentName: 'Grok' }) + expect( + transcript.some( + (row) => + row.body.kind === 'status' && + (row.itemId.includes('cut-turn-notice') || + readAgentSessionFailureFact(row.body.failure)?.kind === 'providerExited') + ) + ).toBe(true) await host.close(SESSION, 'user-close') }) }) diff --git a/src/main/acp/acp-structured-lane.ts b/src/main/acp/acp-structured-lane.ts index d73f6effa6c2..958d20dc47ef 100644 --- a/src/main/acp/acp-structured-lane.ts +++ b/src/main/acp/acp-structured-lane.ts @@ -3,8 +3,6 @@ // backpressure holds every later one until the sink drains, so nothing is reordered or dropped. import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' -import type { StructuredAgentSessionTransitionJournal } from '../native-chat/agent-session-wire/structured-agent-session-transition' import { createProviderTimelineAssembler, type ProviderTimelineAssembler @@ -18,23 +16,12 @@ import { AcpTimelineTranslator } from './acp-timeline-translator' /** How long a held event waits for the sink to say it drained before trying again on its own. */ const BACKPRESSURE_RETRY_MS = 250 -/** The committed rows in journal order, which the translator reads to recognise replayed work. */ -function journalRenderItems( - journal: StructuredAgentSessionTransitionJournal | null -): AgentJournalRenderItem[] { - if (!journal) { - return [] - } - const order: { itemId: string; sequence: number }[] = [] - journal.visitItems((itemId, sequence) => order.push({ itemId, sequence })) - order.sort((left, right) => left.sequence - right.sequence) - return order.flatMap(({ itemId }) => journal.item(itemId) ?? []) -} - export type AcpStructuredLaneDeps = { sink: ProviderTimelineSink sessionId: string agent: string + /** The agent's name as a person reads it, for the rows that name it. */ + agentName: string generation: string providerSessionId: string dialect: AcpDialect @@ -57,8 +44,8 @@ export class AcpStructuredLane { constructor(private readonly deps: AcpStructuredLaneDeps) { this.translator = new AcpTimelineTranslator({ sessionId: deps.providerSessionId, - journalItems: () => journalRenderItems(deps.sink.journalItems()), - dialect: deps.dialect + dialect: deps.dialect, + agentName: deps.agentName }) this.requestIdentity = createLegacyProviderTimelineIdentityScheme({ agent: deps.agent, diff --git a/src/main/acp/acp-structured-session-adapter-resume.test.ts b/src/main/acp/acp-structured-session-adapter-resume.test.ts index b7666f2ccdc5..a385fd3ffadb 100644 --- a/src/main/acp/acp-structured-session-adapter-resume.test.ts +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it } from 'vitest' -import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { @@ -15,7 +14,8 @@ import { openAcpAdapterRig, PROVIDER_SESSION, replyChunk, - sendHello + sendHello, + type AcpAdapterRig } from './acp-structured-adapter.test-support' afterEach(async () => { @@ -28,85 +28,130 @@ const hello: AgentJournalMessageItem = { blocks: [{ type: 'text', text: 'hello' }] } -const userChunk = (text: string) => ({ - sessionId: PROVIDER_SESSION, - update: { sessionUpdate: 'user_message_chunk', content: { type: 'text', text } }, - _meta: { isReplay: true } -}) +/** Grok's own capabilities: it loads and resumes sessions. */ +const RESUMES = { agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } } } +const resume = { launch: { resume: { sessionId: PROVIDER_SESSION, replaceableKey: null } } } -/** Grok answers every load after the first by replaying the turn `promptId` it saved. */ -function replaysOnReload(promptId: string, reply: string) { - let loads = 0 - return (agent: AcpScriptedAgent) => - agent.on('session/load', (frame) => { - if (loads++ > 0) { - agent.notify('session/update', userChunk('hello')) - agent.notify('session/update', replyChunk(promptId, reply, { isReplay: true })) - } - agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) - }) +/** Everything Grok may send while it reattaches: its saved exchange marked as replay, a task the + * dead process left running ended by the restart, and its context usage. */ +function sendsWhileAttaching(agent: AcpScriptedAgent, reply: string): void { + agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'hello' } }, + _meta: { isReplay: true } + }) + agent.notify('session/update', replyChunk('prompt:m1', reply, { isReplay: true })) + agent.notify('x.ai/task_completed', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'task_completed', + task_snapshot: { task_id: 'task-orphan', command: 'sleep 600', signal: 'session_restart' } + } + }) + agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'usage_update', used: 4_000, size: 200_000 } + }) } -const resume = { launch: { resume: { sessionId: PROVIDER_SESSION, replaceableKey: null } } } +/** Orca's send of `hello` as `m1` and Grok's reply, ended or cut off. */ +async function exchange(rig: AcpAdapterRig, reply: string, end: boolean): Promise { + await rig.rig.eventSink.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, { + turnScope: { kind: 'thread' } + }) + await sendHello(rig, 'm1') + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:m1', reply)) + if (end) { + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + } + await rig.settle() +} -describe('ACP resume reconciles what the agent replays against the journal', () => { - it('writes nothing again for a turn the journal already settled', async () => { - const rig = await openAcpAdapterRig({ ...resume, script: replaysOnReload('prompt:m1', 'hi') }) - await rig.acquire() - await rig.rig.eventSink.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, { - turnScope: { kind: 'thread' } +const texts = async (rig: AcpAdapterRig) => + (await rig.rig.rows()).flatMap((row) => messageText(row.body) ?? []) + +describe('reattaching a Grok chat the journal holds', () => { + it('resumes with session/resume and writes nothing Grok sends while it resumes', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + initialize: RESUMES, + script: (agent) => + agent.on('session/resume', (frame) => { + sendsWhileAttaching(agent, 'hi') + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) }) - await sendHello(rig, 'm1') - const prompt = await rig.frame('session/prompt') - rig.child().agent.notify('session/update', replyChunk('prompt:m1', 'hi')) - rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + await rig.acquire() + await exchange(rig, 'hi', true) + const before = (await rig.rig.rows()).map((row) => row.itemId) + await rig.adapter.closeSession(SESSION) + await rig.acquire({ fence: 2 }) await rig.settle() + expect(rig.sent('session/resume')).toHaveLength(1) + expect(rig.sent('session/load')).toHaveLength(0) + // No second user bubble, no stale task row, and no turn opened by the resume's own traffic. + expect((await rig.rig.rows()).map((row) => row.itemId)).toEqual(before) + expect(await texts(rig)).toEqual(['hello', 'hi']) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 2 })).resolves.toEqual({ + cancelled: false, + refusal: { turnNotRunning: true } + }) + }) + + it('loads an agent that only loads, writing none of what it replays', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + script: (agent) => + agent.on('session/load', (frame) => { + sendsWhileAttaching(agent, 'hi') + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + }) + await rig.acquire() + await exchange(rig, 'hi', true) const before = await rig.rig.rows() - expect(before.some((row) => messageText(row.body) === 'hi')).toBe(true) await rig.adapter.closeSession(SESSION) await rig.acquire({ fence: 2 }) await rig.settle() - expect(await rig.rig.rows()).toEqual(before) + expect(rig.sent('session/resume')).toHaveLength(0) + expect((await rig.rig.rows()).map((row) => row.itemId)).toEqual(before.map((row) => row.itemId)) + expect(await texts(rig)).toEqual(['hello', 'hi']) }) - it('takes the rest of a reply Grok saved for a turn that ended without its end', async () => { + it('leaves a reply cut off mid-turn as it was cut, never completed from what Grok saved', async () => { const rig = await openAcpAdapterRig({ ...resume, - script: replaysOnReload('prompt:m1', 'complete saved reply') + script: (agent) => + agent.on('session/load', (frame) => { + sendsWhileAttaching(agent, 'complete saved reply') + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) }) await rig.acquire() - await rig.rig.eventSink.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, { - turnScope: { kind: 'thread' } - }) - await sendHello(rig, 'm1') - await rig.frame('session/prompt') - rig.child().agent.notify('session/update', replyChunk('prompt:m1', 'complete')) - await rig.settle() - // The child goes mid-reply: the turn is left unverifiable, not completed. + await exchange(rig, 'complete', false) await rig.adapter.closeSession(SESSION) await rig.acquire({ fence: 2 }) await rig.settle() const rows = await rig.rig.rows() - expect(rows.flatMap((row) => messageText(row.body) ?? [])).toEqual([ - 'hello', - 'complete saved reply' - ]) + expect(await texts(rig)).toEqual(['hello', 'complete']) expect(rows.flatMap((row) => readAgentJournalTurn(row.body)?.state ?? [])).toEqual([ 'unverifiable' ]) }) - it('writes nothing for a completed turn replayed into a sink bound only after the load', async () => { - const rig = await openAcpAdapterRig({ ...resume, script: replaysOnReload('prompt:m1', 'hi') }) - await rig.acquire() - await rig.rig.eventSink.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, { - turnScope: { kind: 'thread' } + it('writes nothing of the reattach into a sink the host binds only after the start', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + initialize: RESUMES, + script: (agent) => + agent.on('session/resume', (frame) => { + sendsWhileAttaching(agent, 'hi') + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) }) - await sendHello(rig, 'm1') - const prompt = await rig.frame('session/prompt') - rig.child().agent.notify('session/update', replyChunk('prompt:m1', 'hi')) - rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) - await rig.settle() + await rig.acquire() + await exchange(rig, 'hi', true) await rig.adapter.closeSession(SESSION) const before = await rig.rig.rows() // The host's order: a fresh sink per start, bound to the journal once the start succeeded. @@ -125,52 +170,35 @@ describe('ACP resume reconciles what the agent replays against the journal', () }) deferred.bind({ journal: rig.rig.journal, fence: 2, publish: () => {} }) expect(await deferred.drained()).toMatchObject({ ok: true }) - expect(await rig.rig.rows()).toEqual(before) + expect((await rig.rig.rows()).map((row) => row.itemId)).toEqual(before.map((row) => row.itemId)) await rig.adapter.closeSession(SESSION) deferred.close() }) - it('adopts a reply Grok saved that Orca never wrote, joined to the send already in the journal', async () => { + it('starts a new session in place of a created one that session/resume reports missing', async () => { const rig = await openAcpAdapterRig({ - ...resume, + launch: { resume: { sessionId: 'never-saved', replaceableKey: 'acp:grok:never-saved' } }, + initialize: RESUMES, script: (agent) => - agent.on('session/load', (frame) => { - agent.notify('session/update', userChunk('hello')) - agent.notify( - 'session/update', - replyChunk('prompt:old-message', 'Saved reply after host crash', { isReplay: true }) - ) - agent.reply(frame, { configOptions: [] }) - }) + agent.on('session/resume', (frame) => agent.fail(frame, -32002, 'Resource not found')) }) - // Orca wrote the send, then crashed before any of Grok's reply reached the journal. - await rig.rig.eventSink.appendItem( - { provider: 'orca', clientMessageId: 'old-message' }, - hello, - { - turnScope: { kind: 'thread' } - } - ) - await rig.settle() - await rig.acquire({ fence: 2 }) - await rig.settle() - const rows = await rig.rig.rows() - const messages = rows.filter((row) => row.body.kind === 'message') - expect(messages.map((row) => messageText(row.body))).toEqual([ - 'hello', - 'Saved reply after host crash' - ]) - const turn = rows.flatMap((row) => readAgentJournalTurn(row.body) ?? []) - expect(turn).toMatchObject([ - { state: 'completed', userItemId: agentJournalSubmissionKey('old-message') } - ]) + const acquired = await rig.acquire() + expect(acquired.link).toMatchObject({ + origin: 'created', + handle: { nativeId: PROVIDER_SESSION }, + supersedesKey: 'acp:grok:never-saved' + }) + // The failed resume left the translator taking prompts. + await exchange(rig, 'hi', true) + expect(await texts(rig)).toEqual(['hello', 'hi']) }) - it('keeps the slash commands Grok reports while it loads', async () => { + it('keeps the slash commands Grok reports while it resumes', async () => { const rig = await openAcpAdapterRig({ ...resume, + initialize: RESUMES, script: (agent) => - agent.on('session/load', (frame) => { + agent.on('session/resume', (frame) => { agent.notify('session/update', { sessionId: PROVIDER_SESSION, update: { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replayed-input.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replayed-input.test.ts deleted file mode 100644 index a315b16038d6..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replayed-input.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { - assistantText, - closeProviderTimelineRigs, - messageText, - openProviderTimelineRig, - openUnboundProviderTimelineAssembler, - type ProviderTimelineRig -} from './provider-timeline-assembler-test-support' - -afterEach(closeProviderTimelineRigs) - -const user = (text: string): AgentJournalMessageItem => ({ - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text }] -}) - -async function userMessages(rig: ProviderTimelineRig): Promise<(string | undefined)[]> { - return (await rig.rows()) - .filter((row) => row.body.kind === 'message' && row.body.role === 'user') - .map((row) => messageText(row.body)) -} - -/** Orca's send `m1`, journaled, opening turn `t1`; the child then went without ending it. */ -async function sendOpenedTurn(rig: ProviderTimelineRig): Promise { - rig.eventSink.appendItem({ provider: 'orca', clientMessageId: 'm1' }, user('hello'), { - turnScope: { kind: 'thread' } - }) - rig.assembler.apply({ - type: 'input.accepted', - clientMessageId: 'm1', - requestedAt: 900, - join: { turn: 't1' } - }) - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'session.ended', verdict: { state: 'unverifiable' } }) - await rig.rows() -} - -describe('a replayed user message is decided against the journal at the write', () => { - it('writes nothing for a turn the send opened, through a sink bound only after the load', async () => { - const rig = await openProviderTimelineRig() - await sendOpenedTurn(rig) - const load = openUnboundProviderTimelineAssembler(rig.journal) - // A copy the provider names by its own id, so only the turn's opener says it is the send. - load.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - load.assembler.apply({ - type: 'input.replayed', - item: 'replay-user:t1:provider-message-7', - body: user('hello'), - join: { turn: 't1' } - }) - await load.bind() - expect(await userMessages(rig)).toEqual(['hello']) - }) - - it('makes the journaled send the opener of a turn that never named it', async () => { - const rig = await openProviderTimelineRig() - rig.eventSink.appendItem({ provider: 'orca', clientMessageId: 'm1' }, user('hello'), { - turnScope: { kind: 'thread' } - }) - // The turn opened, then the host went before the send's echo reached it. - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - await rig.rows() - const load = openUnboundProviderTimelineAssembler(rig.journal) - load.assembler.apply({ - type: 'input.replayed', - item: 'replay-user:t1:', - body: user('hello'), - clientMessageId: 'm1', - join: { turn: 't1' } - }) - await load.bind() - expect(await userMessages(rig)).toEqual(['hello']) - expect(await rig.turn('t1')).toMatchObject({ userItemId: agentJournalSubmissionKey('m1') }) - }) - - it('writes the message of a turn that has none, and grows that row as it is resent', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - for (const text of ['hel', 'hello']) { - rig.assembler.apply({ - type: 'input.replayed', - item: 'replay-user:t1:', - body: user(text), - join: { turn: 't1' } - }) - } - expect(await userMessages(rig)).toEqual(['hello']) - }) - - it('leaves a turn the provider completed exactly as the journal holds it', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'item.update', item: 'reply', body: assistantText('short') }) - rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) - const before = await rig.rows() - const load = openUnboundProviderTimelineAssembler(rig.journal) - load.assembler.apply({ - type: 'input.replayed', - item: 'replay-user:t1:', - body: user('hello'), - join: { turn: 't1' } - }) - load.assembler.apply({ - type: 'item.update', - item: 'reply', - body: assistantText('the provider saved more'), - join: { turn: 't1' }, - replay: true - }) - await load.bind() - expect(await rig.rows()).toEqual(before) - }) - - it('takes a reply the journal lacks once, and leaves it as it is on the next replay', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'item.update', item: 'reply', body: assistantText('part') }) - rig.assembler.apply({ type: 'session.ended', verdict: { state: 'unverifiable' } }) - await rig.rows() - const replay = { - type: 'item.update', - item: 'reply', - body: assistantText('part and the rest'), - join: { turn: 't1' }, - replay: true - } as const - const first = openUnboundProviderTimelineAssembler(rig.journal) - first.assembler.apply(replay) - await first.bind() - const recovered = await rig.rows() - expect(recovered.map((row) => messageText(row.body))).toContain('part and the rest') - const second = openUnboundProviderTimelineAssembler(rig.journal) - second.assembler.apply(replay) - await second.bind() - expect(await rig.rows()).toEqual(recovered) - }) -}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts index 953b9a72d8a1..e5c985aaece1 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -10,7 +10,6 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { StructuredAgentSessionItemAppendOptions } from '../agent-session-wire/structured-agent-session-event-sink' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' @@ -130,18 +129,3 @@ export function decideProviderTimelineEvent( return decideSessionEnd(input, event) } } - -/** A replayed event for a turn the provider itself completed: the journal holds that turn whole. - * Completion is final, so planning's possibly older journal never drops what execution would keep. */ -export function replaysCompletedTurn( - input: ProviderTimelineDecisionInput, - join: { turn?: string } | undefined -): boolean { - if (join?.turn === undefined || !input.journal) { - return false - } - const turn = input.context.joins.turn(providerKey(join.turn), input.state.namespace) - return ( - readAgentJournalTurn(input.journal.itemBody(turn.itemId) ?? undefined)?.state === 'completed' - ) -} From d2764bc9086e44db9dbc5090cb21372fcd16048c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:26:23 -0700 Subject: [PATCH 163/269] refactor(native-chat): a failed Grok reattach needs no window close of its own; its lane is replaced --- src/main/acp/acp-structured-acquire.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 899f02f59f24..7f321473cf4f 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -216,19 +216,15 @@ export async function acquireAcpStructuredSession(input: { sessionId: resume.sessionId, resumePreference: 'resume' }) + attaching.apply(attaching.translator.finishLoad(now())) } catch (error) { const notFound = error instanceof AcpRpcError && error.code === ACP_RESOURCE_NOT_FOUND if (!notFound || resume.replaceableKey === null) { throw error } - // A session this chat created and the agent never saved: a new one takes its place. + // A session this chat created and the agent never saved: a new one takes its place, with a + // new lane, so nothing of the failed attach's window outlives it. supersedesKey = resume.replaceableKey - } finally { - // Also after a failed attach, so the translator takes prompts again. - const usage = attaching.translator.finishLoad(now()) - if (started) { - attaching.apply(usage) - } } } if (!started || !liveLane) { From cba2dfaa6c922c465695f79aeeea202c37151430 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:28:08 -0700 Subject: [PATCH 164/269] test(native-chat): D3's merged tests use the shipped declarations and the launch options main requires --- ...ssion-claude-stop-exit-ends-record.test.ts | 4 ++-- ...nt-session-launch-registered-agent.test.ts | 24 ++++--------------- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts index 401d5db7eda6..31c21a18e5b9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -20,7 +20,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { HOST_TEST_NOW as NOW, @@ -84,7 +84,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, + agents: claudeAndCodexDeclared(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/renderer/src/lib/structured-agent-session-launch-registered-agent.test.ts b/src/renderer/src/lib/structured-agent-session-launch-registered-agent.test.ts index 799ce2009010..772a501f64a2 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-registered-agent.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-registered-agent.test.ts @@ -1,8 +1,6 @@ // @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest' -import { act, createElement } from 'react' -import { createRoot } from 'react-dom/client' const mocks = vi.hoisted(() => ({ call: vi.fn() })) @@ -21,23 +19,11 @@ vi.mock('@/store', () => ({ })) import { + getStructuredAgentLaunchStatus, hasStructuredAgentLaunchInWorktree, - startStructuredAgentLaunch, - useStructuredAgentLaunchPendingAgents + startStructuredAgentLaunch } from './structured-agent-session-launch' -async function pendingAgents(worktreeId: string): Promise { - let seen: string[] = [] - function Probe(): null { - seen = [...useStructuredAgentLaunchPendingAgents(worktreeId)] - return null - } - const root = createRoot(document.createElement('div')) - await act(async () => root.render(createElement(Probe))) - act(() => root.unmount()) - return seen -} - describe('a launch of a host-registered agent', () => { beforeEach(() => { vi.clearAllMocks() @@ -51,7 +37,7 @@ describe('a launch of a host-registered agent', () => { const worktreeId = 'wt-grok' expect(hasStructuredAgentLaunchInWorktree(worktreeId)).toBe(false) - const launch = startStructuredAgentLaunch(worktreeId, 'grok') + const launch = startStructuredAgentLaunch(worktreeId, 'grok', { requestId: 'request-1' }) await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledWith( expect.anything(), @@ -63,7 +49,7 @@ describe('a launch of a host-registered agent', () => { expect(launch.sessionId.startsWith('grok_')).toBe(true) expect(hasStructuredAgentLaunchInWorktree(worktreeId)).toBe(true) expect(hasStructuredAgentLaunchInWorktree('wt-other')).toBe(false) - expect(await pendingAgents(worktreeId)).toEqual(['grok']) - expect(await pendingAgents('wt-other')).toEqual([]) + expect(getStructuredAgentLaunchStatus(worktreeId, 'grok')).toBe('pending') + expect(getStructuredAgentLaunchStatus('wt-other', 'grok')).toBe('idle') }) }) From da34dda06a2a2057deae3c3b655dc27c054a701e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:30:37 -0700 Subject: [PATCH 165/269] fix(native-chat): typecheck fallout of the base merges; any agent's empty chat is reusable Main's idle-empty-chat lookup and launch join now take any registered agent, as the rest of the launch path does. The refusal check moved into the prompt turns and the prompt-block conversion beside the turns that send it, keeping both files in their line limit. --- src/main/acp/acp-prompt-turns.ts | 9 +++++++++ .../acp/acp-structured-host-lifecycle.test.ts | 6 ++---- .../acp/acp-structured-session-adapter.ts | 19 +++---------------- src/main/acp/acp-structured-turns.ts | 18 +++++++++++++++++- src/main/acp/acp-timeline-translator.ts | 7 +------ ...ktop-renderer-runtime-capabilities.test.ts | 8 +++++++- .../structured-agent-session-host.ts | 1 + ...tructured-agent-session-transition.test.ts | 2 +- ...ent-launch-route-registered-agents.test.ts | 2 +- ...tructured-agent-session-idle-empty-chat.ts | 4 ++-- .../lib/structured-agent-session-launch.ts | 2 +- 11 files changed, 45 insertions(+), 33 deletions(-) diff --git a/src/main/acp/acp-prompt-turns.ts b/src/main/acp/acp-prompt-turns.ts index c9fe584dcabe..257ffc68523d 100644 --- a/src/main/acp/acp-prompt-turns.ts +++ b/src/main/acp/acp-prompt-turns.ts @@ -33,6 +33,15 @@ export class AcpPromptTurns { this.current = undefined } + /** Forgets a prompt the agent refused before its turn opened; false for any other. */ + refuse(clientMessageId: string): boolean { + if (this.current?.clientMessageId !== clientMessageId || this.current.opened) { + return false + } + this.current = undefined + return true + } + start(turn: string, at: number): ProviderTimelineEvent[] { const prompt = this.current if (prompt?.turn !== turn || prompt.opened) { diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index ba1bf59832e9..8318988a3fc7 100644 --- a/src/main/acp/acp-structured-host-lifecycle.test.ts +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -81,9 +81,7 @@ async function openHostRig( ) { const state = hostTestState() const store = await openTestAgentSessionRecordStore(state.root, { - agents: agentSessionStoredAgents([ - { agent: 'grok', handleTransport: 'acp', accountHomeVariable: 'GROK_HOME' } - ]) + agents: agentSessionStoredAgents([{ agent: 'grok' }]) }) let generation = 0 // As the runtime wires it: every exit the adapter observes reaches the host. @@ -183,7 +181,7 @@ describe('resuming a Grok chat through the host', () => { expect(count.resumes).toBe(1) expect(await messages()).toEqual(['hello', 'hi']) const after = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items - expect(after.filter((row) => row.body.kind === 'background-task')).toEqual([]) + expect(after.filter((row) => row.itemId.includes('background-task'))).toEqual([]) expect(after.filter((row) => readAgentJournalTurn(row.body))).toHaveLength( before.filter((row) => readAgentJournalTurn(row.body)).length ) diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index bb15da2d8121..0bd185321d6e 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -29,11 +29,11 @@ import { type AcpStructuredSession } from './acp-structured-session' import { AcpStructuredStarts, type AcpStartAttempt } from './acp-structured-starts' +import { acpPromptBlocks } from './acp-structured-turns' import { ACP_CANCEL_TIMEOUT_MS, type AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' -import type { ContentBlock } from './generated/acp-protocol.generated' export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter { /** Live children, and ones whose exit is not yet proven; a proven exit removes its entry. */ @@ -132,7 +132,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte beforeDispatch?: () => Promise }): Promise { const session = this.live(input.sessionId) - const prompt = this.promptBlocks(input.body) + const prompt = acpPromptBlocks(input.body) if (!prompt) { return { state: 'rejected', @@ -260,8 +260,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } } - /** A requested close. The root's exit proves it; a process tree the close could not prove gone is - * reported to the caller that owns the child's record, as the adapter contract asks. */ + /** A requested close: proven by the root's exit; a tree not proven gone is the caller's to report. */ private async close(sessionId: string): Promise { const child = this.sessions.get(sessionId)?.child const closed = await this.stop(sessionId, true) @@ -334,18 +333,6 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte void this.stop(session.sessionId, false) } - private promptBlocks(body: AgentJournalMessageItem): ContentBlock[] | null { - const blocks: ContentBlock[] = [] - for (const block of body.blocks) { - if (block.type !== 'text') { - // Images wait for an ACP image path; the chat offers none while `imagePrompts` is off. - return null - } - blocks.push({ type: 'text', text: block.text }) - } - return blocks - } - private live(sessionId: string): AcpStructuredSession { const session = this.sessions.get(sessionId) if (!session || session.journalClosed !== null) { diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index 5e8b1c6e7aa0..7d83519d5324 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -13,7 +13,10 @@ import { agentSessionFailureWords, type AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem +} from '../../shared/agent-session-journal-types' import { AcpAgentError } from './acp-errors' import type { AcpSessionRuntime } from './acp-session-runtime' import type { AcpStructuredLane } from './acp-structured-lane' @@ -25,6 +28,19 @@ export type AcpDispatchSettlement = { clientMessageId: string } & ( | { state: 'unknown'; reason: string } ) +/** A person's message as an ACP prompt; null when it carries what the agent cannot take. */ +export function acpPromptBlocks(body: AgentJournalMessageItem): ContentBlock[] | null { + const blocks: ContentBlock[] = [] + for (const block of body.blocks) { + if (block.type !== 'text') { + // Images wait for an ACP image path; the chat offers none while `imagePrompts` is off. + return null + } + blocks.push({ type: 'text', text: block.text }) + } + return blocks +} + type Send = { clientMessageId: string; prompt: ContentBlock[]; requestedAt: number } export type AcpStructuredTurnsDeps = { diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 333a8a34acf9..2fa6b2c0c9be 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -102,12 +102,7 @@ export class AcpTimelineTranslator { /** The agent refused the prompt before its turn began: forgets it and answers the agent's reason. * Null once the turn opened, when the refusal ends that turn instead (`promptFailed`). */ promptRefused(clientMessageId: string, error: AcpAgentError): string | null { - const prompt = this.prompts.current - if (prompt?.clientMessageId !== clientMessageId || prompt.opened) { - return null - } - this.prompts.current = undefined - return acpPromptErrorDetail(this.dialect, error) + return this.prompts.refuse(clientMessageId) ? acpPromptErrorDetail(this.dialect, error) : null } private finishPrompt( diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 541ec9828621..47e9ec502346 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -109,7 +109,13 @@ describe('desktop renderer runtime client capabilities', () => { ['a paired host', PAIRED_HOST_RECEIVES], ['its own main process', DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] ] as const)('reads every agent %s registered', (_host, clientCapabilities) => { - expect(structuredAgentsReadBy({ clientKind: 'runtime', clientCapabilities })).toBeUndefined() + expect( + structuredAgentsReadBy({ clientKind: 'runtime', clientCapabilities }, [ + { agent: 'claude' }, + { agent: 'codex' }, + { agent: 'grok' } + ]) + ).toBeUndefined() }) it('diverges from what a paired host receives only where a decision was recorded', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index da5bdeb6dfb8..175ccc958afa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -1,3 +1,4 @@ +import type { StructuredAgentDefinition } from './structured-agent-definition' import type { AgentSessionRewindParams } from '../../../shared/agent-session-rewind' import { rewindStructuredAgentSession } from './structured-agent-session-rewind' import { StructuredConversationCommandController } from './structured-conversation-command-controller' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts index 52714d2fcafa..d9a152d0e6ed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -99,7 +99,7 @@ async function owedRig() { workspaceId: 'workspace-1', hostId: 'local', agent: 'grok', - providerHandle: { kind: 'opaque', agent: 'grok', value: 'provider-session-1' } + providerHandle: { transport: 'acp', agent: 'grok', nativeId: 'provider-session-1' } }, now: () => 1_000 }) diff --git a/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts b/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts index 0bc5918801c5..db5024bbb897 100644 --- a/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts +++ b/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts @@ -82,7 +82,7 @@ function store( } const routeFor = (appStore: AgentLaunchRouteStore, agent: TuiAgent, workspace = LOCAL) => - planAgentSessionLaunch(appStore, { agent, workspace }).route + planAgentSessionLaunch(appStore, { requestId: 'request-1', agent, workspace }).route beforeEach(() => { resetHostStructuredAgentsForTests() diff --git a/src/renderer/src/lib/structured-agent-session-idle-empty-chat.ts b/src/renderer/src/lib/structured-agent-session-idle-empty-chat.ts index 45d7970217ef..f4162aa2957f 100644 --- a/src/renderer/src/lib/structured-agent-session-idle-empty-chat.ts +++ b/src/renderer/src/lib/structured-agent-session-idle-empty-chat.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import type { ExecutionHostId } from '../../../shared/execution-host' import type { Tab } from '../../../shared/tab-types' import { useAppStore } from '@/store' @@ -38,7 +38,7 @@ function holdsUnadoptedLaunchDraft(tabId: string): boolean { * sent into and whose composer is untouched. Prefers the group's active tab, else the newest. */ export function findIdleEmptyStructuredChat( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, executionHostId?: ExecutionHostId, groupId?: string ): IdleEmptyStructuredChat | undefined { diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index abf0eac2dc69..917a2fba29f9 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -158,7 +158,7 @@ function restartStructuredLaunchState(state: StructuredLaunchState): void { function joinStructuredLaunchState( existing: StructuredLaunchState, - agent: AgentSessionHandleProvider, + agent: TuiAgent, options: StructuredAgentLaunchOptions, request: StructuredLaunchRequest ): StructuredLaunchStateResult | undefined { From 4b4febc4e00f9288641ed31d47932f9988df5dfe Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:43:42 -0700 Subject: [PATCH 166/269] fix(native-chat): a Grok Stop ends the process once Grok settles its turn; the next send resumes Grok's session/cancel ends only the running turn: work it already moved to the background keeps running and can begin a turn of its own after the person pressed Stop. Stop is now a session boundary, as it is for Claude: the cancel answers open requests and lets Grok end the turn, the host waits a bounded grace for that, then ends the process; the next send relaunches and resumes. The adapter's own bounded close of a turn Grok began is gone. Its named-turn check stays: the host ends the session unless the provider declines a Stop naming a turn that has since ended. --- .../acp/acp-structured-host-lifecycle.test.ts | 132 +----------- src/main/acp/acp-structured-host-stop.test.ts | 196 ++++++++++++++++++ .../acp/acp-structured-host.test-support.ts | 130 ++++++++++++ .../acp-structured-session-adapter-deps.ts | 8 +- ...ructured-session-adapter-lifecycle.test.ts | 18 +- .../acp/acp-structured-session-adapter.ts | 68 +++--- src/main/acp/acp-structured-turns.ts | 19 ++ 7 files changed, 401 insertions(+), 170 deletions(-) create mode 100644 src/main/acp/acp-structured-host-stop.test.ts create mode 100644 src/main/acp/acp-structured-host.test-support.ts diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index 8318988a3fc7..6a353e6f8daf 100644 --- a/src/main/acp/acp-structured-host-lifecycle.test.ts +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -3,146 +3,34 @@ // queue), with a scripted Grok, the real record store and an on-disk journal. import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' -import { agentSessionStoredAgents } from '../../shared/agent-session-stored-agent' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' import { withNativeChatCutTurnNotices } from '../../shared/native-chat-cut-turn-notice' -import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' -import { - closeProviderTimelineRigs, - messageText -} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' -import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { CALLER, - envelope, - hostTestState, - replaceHostTestState + envelope } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' -import { - HOST_TEST_NOW, - HOST_TEST_SESSION as SESSION, - hostTestAttachParams -} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { AgentSessionRecoveryCapsule } from '../runtime/agent-session-recovery-capsule' -import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' import { - GROK, GROK_CONFIG_OPTIONS, - openAcpAdapterRig, PROVIDER_SESSION, replyChunk, waitFor } from './acp-structured-adapter.test-support' -import { acpStructuredAgentDefinition } from './acp-structured-agent-definitions' -import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' -import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' +import { + attachParams, + hello, + launch, + openHostRig, + RESUMES +} from './acp-structured-host.test-support' afterEach(async () => { await closeProviderTimelineRigs() }) -const hello: AgentJournalMessageItem = { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: 'hello' }] -} - -const attachParams = (fence: number | null = null) => - hostTestAttachParams(fence, { - provider: 'grok', - agent: 'grok', - accountHome: { variable: 'GROK_HOME', path: '/grok' }, - providerHandle: undefined - }) - -/** A launch that resumes the provider session once the chat has one. */ -function launch(resume: () => boolean): () => Promise { - return async () => ({ - spec: GROK, - command: '/fake/grok', - args: [], - cwd: '/workspace', - env: {}, - fullAccess: false, - resume: resume() ? { sessionId: PROVIDER_SESSION, replaceableKey: null } : null - }) -} - -async function openHostRig( - options: { - script?: (agent: AcpScriptedAgent) => void - initialize?: Record - deps?: Partial - } = {} -) { - const state = hostTestState() - const store = await openTestAgentSessionRecordStore(state.root, { - agents: agentSessionStoredAgents([{ agent: 'grok' }]) - }) - let generation = 0 - // As the runtime wires it: every exit the adapter observes reaches the host. - const hosted: { host: StructuredAgentSessionHost | null } = { host: null } - const rig = await openAcpAdapterRig({ - ...options, - deps: { - onEvent: (event) => void hosted.host?.handleAdapterEvent(event), - now: () => HOST_TEST_NOW, - readProcessStartTime: async () => 1_700_000_000_000 + ++generation, - mintGeneration: () => `generation-${generation}`, - ...options.deps - } - }) - const host = new StructuredAgentSessionHost({ - agents: new StructuredAgentRegistry([ - { definition: acpStructuredAgentDefinition(GROK), adapter: rig.adapter } - ]), - logger: state.log.logger, - store, - adapter: rig.adapter, - journalDatabase: openTestJournalHostDatabase(state.root), - recoveryCapsule: new AgentSessionRecoveryCapsule(state.root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => HOST_TEST_NOW - }) - hosted.host = host - replaceHostTestState({ store, host }) - const fence = () => store.getRecord(SESSION)?.lease.runtimeFence ?? 1 - const messages = async () => - (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items - .filter((row) => row.body.kind === 'message') - .map((row) => messageText(row.body)) - /** Orca's send of `hello` as `m1`, and Grok's reply `text`, ended or left running. */ - const exchange = async (text: string, end: boolean) => { - const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal - await journal.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, { - fence: fence(), - turnScope: { kind: 'thread' } - }) - await rig.adapter.dispatch({ - sessionId: SESSION, - clientMessageId: 'm1', - body: hello, - fence: fence() - }) - const prompt = await rig.frame('session/prompt') - rig.child().agent.notify('session/update', replyChunk('prompt:m1', text)) - if (end) { - rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) - } - await rig.settle() - await host.flushStreamedEvents(SESSION) - } - return { rig, host, store, fence, messages, exchange } -} - -/** Grok's capabilities: it loads and resumes sessions. */ -const RESUMES = { agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } } } - /** On every resume, Grok sends what its resume may carry: the saved `reply` marked as replay, and a * task the dead process left running, ended by the restart. */ function resumes(reply: string, count: { resumes: number }) { diff --git a/src/main/acp/acp-structured-host-stop.test.ts b/src/main/acp/acp-structured-host-stop.test.ts new file mode 100644 index 000000000000..ea2e5a543c8f --- /dev/null +++ b/src/main/acp/acp-structured-host-stop.test.ts @@ -0,0 +1,196 @@ +// A Grok chat's Stop through the real host: it ends the process once Grok settles its turn, and the +// next send resumes the session. + +import { afterEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { withNativeChatCutTurnNotices } from '../../shared/native-chat-cut-turn-notice' +import { + closeProviderTimelineRigs, + providerTurnId +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { + CALLER, + envelope +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { FakeFrame } from './acp-scripted-agent.test-support' +import { + GROK_CONFIG_OPTIONS, + PROVIDER_SESSION, + replyChunk, + waitFor, + type FakeAcpChild +} from './acp-structured-adapter.test-support' +import { attachParams, launch, openHostRig, RESUMES } from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const promptMeta = z.object({ _meta: z.object({ promptId: z.string() }) }) +const promptIdOf = (frame: FakeFrame): string => promptMeta.parse(frame.params)._meta.promptId + +function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +async function send(host: StructuredAgentSessionHost, text: string): Promise { + const body = message(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error('send refused') + } + return sent.value.clientMessageId +} + +function stop(host: StructuredAgentSessionHost, turnId?: string) { + return turnId === undefined + ? host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + : host.cancel(CALLER, { envelope: envelope('agentSession.cancel', { turnId }), turnId }) +} + +const methods = (child: FakeAcpChild, method: string) => + child.agent.frames.filter((frame) => frame.method === method) + +/** A Grok that resumes its session, counting each resume. */ +async function openResumingRig() { + const count = { resumes: 0 } + let resumed = false + const rig = await openHostRig({ + initialize: RESUMES, + script: (agent) => + agent.on('session/resume', (frame) => { + count.resumes += 1 + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }), + deps: { resolveLaunch: launch(() => resumed) } + }) + expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + resumed = true + const rows = async () => { + await rig.host.flushStreamedEvents(SESSION) + return (await rig.host.history({ sessionId: SESSION, direction: 'tail' })).page.items + } + const turns = async () => (await rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + return { ...rig, count, rows, turns } +} + +describe('a Grok chat Stop', () => { + it('ends Grok once it settles the turn, and the next send resumes with no notice', async () => { + const { rig, host, count, rows, turns, messages } = await openResumingRig() + const first = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + first.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'partial')) + first.agent.on('session/cancel', () => first.agent.reply(prompt, { stopReason: 'cancelled' })) + await rig.settle() + await host.flushStreamedEvents(SESSION) + expect(await stop(host)).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(first.exited).toBe(true)) + expect(methods(first, 'session/cancel')).toHaveLength(1) + expect((await turns()).at(-1)).toMatchObject({ state: 'interrupted' }) + + await send(host, 'again') + await waitFor(() => expect(rig.child()).not.toBe(first)) + const second = rig.child() + await rig.frame('session/prompt') + expect(count.resumes).toBe(1) + expect(methods(second, 'session/new')).toHaveLength(0) + expect(await messages()).toEqual(['hello', 'partial', 'again']) + // Only the Stop's own note: no exit or cut-reply notice for a Stop the person asked for. + const transcript = withNativeChatCutTurnNotices(await rows(), { agentName: 'Grok' }) + expect( + transcript.flatMap((row) => (row.body.kind === 'status' ? [row.body.text] : [])) + ).toEqual(['Cancellation requested.']) + await host.close(SESSION, 'user-close') + }) + + it('ends Grok on a Stop of a turn it began itself, once that turn ends', async () => { + const { rig, host, turns } = await openResumingRig() + const { agent } = rig.child() + agent.notify('session/update', replyChunk('task-completed-background-1', 'Working on it')) + agent.on('session/cancel', () => + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'turn_completed', + prompt_id: 'task-completed-background-1', + stop_reason: 'cancelled' + } + }) + ) + await rig.settle() + expect((await turns()).at(-1)).toMatchObject({ state: 'running' }) + expect(await stop(host)).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(rig.child().exited).toBe(true)) + expect(methods(rig.child(), 'session/cancel')).toHaveLength(1) + expect((await turns()).at(-1)).toMatchObject({ state: 'interrupted' }) + await host.close(SESSION, 'user-close') + }) + + it('leaves no Grok behind whose background work could begin a turn after the Stop', async () => { + const { rig, host, turns } = await openResumingRig() + const first = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + first.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'started a build')) + first.agent.on('session/cancel', () => first.agent.reply(prompt, { stopReason: 'cancelled' })) + await rig.settle() + await host.flushStreamedEvents(SESSION) + await stop(host) + await waitFor(() => expect(first.exited).toBe(true)) + const settled = await turns() + // What a kept process would do once its backgrounded build finished. + first.agent.notify('session/update', replyChunk('task-completed-background-1', 'Build done')) + await rig.settle() + expect(await turns()).toEqual(settled) + expect(settled.map((turn) => turn.state)).not.toContain('running') + await host.close(SESSION, 'user-close') + }) +}) + +describe('a Grok chat Stop naming a turn that has ended', () => { + it('stops neither the turn running now nor Grok', async () => { + const { rig, host, turns } = await openResumingRig() + const { agent } = rig.child() + await send(host, 'old') + const old = await rig.frame('session/prompt') + agent.notify('session/update', replyChunk(promptIdOf(old), 'done')) + agent.reply(old, { stopReason: 'end_turn' }) + await rig.settle() + await send(host, 'new') + const current = await rig.frame('session/prompt', 1) + agent.notify('session/update', replyChunk(promptIdOf(current), 'working')) + await rig.settle() + await host.flushStreamedEvents(SESSION) + const stopped = await stop(host, providerTurnId(promptIdOf(old), PROVIDER_SESSION)) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) + await rig.settle() + expect(methods(rig.child(), 'session/cancel')).toHaveLength(0) + expect(rig.child().exited).toBe(false) + expect((await turns()).at(-1)).toMatchObject({ state: 'running' }) + await host.close(SESSION, 'user-close') + }) + + it('stops nothing in the gap before Grok echoes the next prompt', async () => { + const { rig, host } = await openResumingRig() + const { agent } = rig.child() + await send(host, 'old') + const old = await rig.frame('session/prompt') + agent.notify('session/update', replyChunk(promptIdOf(old), 'done')) + agent.reply(old, { stopReason: 'end_turn' }) + await rig.settle() + await send(host, 'new') + await rig.frame('session/prompt', 1) + await host.flushStreamedEvents(SESSION) + const stopped = await stop(host, providerTurnId(promptIdOf(old), PROVIDER_SESSION)) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) + await rig.settle() + expect(methods(rig.child(), 'session/cancel')).toHaveLength(0) + expect(rig.child().exited).toBe(false) + await host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-host.test-support.ts b/src/main/acp/acp-structured-host.test-support.ts new file mode 100644 index 000000000000..42554c544f18 --- /dev/null +++ b/src/main/acp/acp-structured-host.test-support.ts @@ -0,0 +1,130 @@ +// The ACP adapter behind the real host, with a scripted Grok, the real record store and an +// on-disk journal. + +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { agentSessionStoredAgents } from '../../shared/agent-session-stored-agent' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { messageText } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' +import { + hostTestState, + replaceHostTestState +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { AgentSessionRecoveryCapsule } from '../runtime/agent-session-recovery-capsule' +import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' +import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { + GROK, + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk +} from './acp-structured-adapter.test-support' +import { acpStructuredAgentDefinition } from './acp-structured-agent-definitions' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' + +export const hello: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }] +} + +export const attachParams = (fence: number | null = null) => + hostTestAttachParams(fence, { + provider: 'grok', + agent: 'grok', + accountHome: { variable: 'GROK_HOME', path: '/grok' }, + providerHandle: undefined + }) + +/** A launch that resumes the provider session once the chat has one. */ +export function launch(resume: () => boolean): () => Promise { + return async () => ({ + spec: GROK, + command: '/fake/grok', + args: [], + cwd: '/workspace', + env: {}, + fullAccess: false, + resume: resume() ? { sessionId: PROVIDER_SESSION, replaceableKey: null } : null + }) +} + +export async function openHostRig( + options: { + script?: (agent: AcpScriptedAgent) => void + initialize?: Record + deps?: Partial + } = {} +) { + const state = hostTestState() + const store = await openTestAgentSessionRecordStore(state.root, { + agents: agentSessionStoredAgents([{ agent: 'grok' }]) + }) + let generation = 0 + // As the runtime wires it: every exit the adapter observes reaches the host. + const hosted: { host: StructuredAgentSessionHost | null } = { host: null } + const rig = await openAcpAdapterRig({ + ...options, + deps: { + onEvent: (event) => void hosted.host?.handleAdapterEvent(event), + now: () => HOST_TEST_NOW, + readProcessStartTime: async () => 1_700_000_000_000 + ++generation, + mintGeneration: () => `generation-${generation}`, + ...options.deps + } + }) + const host = new StructuredAgentSessionHost({ + agents: new StructuredAgentRegistry([ + { definition: acpStructuredAgentDefinition(GROK), adapter: rig.adapter } + ]), + logger: state.log.logger, + store, + adapter: rig.adapter, + journalDatabase: openTestJournalHostDatabase(state.root), + recoveryCapsule: new AgentSessionRecoveryCapsule(state.root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => HOST_TEST_NOW + }) + hosted.host = host + replaceHostTestState({ store, host }) + const fence = () => store.getRecord(SESSION)?.lease.runtimeFence ?? 1 + const messages = async () => + (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + .filter((row) => row.body.kind === 'message') + .map((row) => messageText(row.body)) + /** Orca's send of `hello` as `m1`, and Grok's reply `text`, ended or left running. */ + const exchange = async (text: string, end: boolean) => { + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + await journal.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, { + fence: fence(), + turnScope: { kind: 'thread' } + }) + await rig.adapter.dispatch({ + sessionId: SESSION, + clientMessageId: 'm1', + body: hello, + fence: fence() + }) + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:m1', text)) + if (end) { + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + } + await rig.settle() + await host.flushStreamedEvents(SESSION) + } + return { rig, host, store, fence, messages, exchange } +} + +/** Grok's capabilities: it loads and resumes sessions. */ +export const RESUMES = { + agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } } +} diff --git a/src/main/acp/acp-structured-session-adapter-deps.ts b/src/main/acp/acp-structured-session-adapter-deps.ts index ccf482834ce1..79239bf3ebcb 100644 --- a/src/main/acp/acp-structured-session-adapter-deps.ts +++ b/src/main/acp/acp-structured-session-adapter-deps.ts @@ -9,8 +9,10 @@ import type { AcpLaunchSpec } from './acp-launch-specs' import type { SpawnAcpStructuredChild } from './acp-structured-child' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' -/** How long a Stop waits for the agent to end its turn before the child is closed. */ +/** How long a cancel waits for the agent to answer its prompt before the connection closes. */ export const ACP_CANCEL_TIMEOUT_MS = 10_000 +/** How long a Stop, counted from its cancel, lets the agent end its turn before the child goes. */ +export const ACP_STOP_GRACE_MS = 4_000 export type AcpStructuredSessionAdapterDeps = { spec: AcpLaunchSpec @@ -31,8 +33,10 @@ export type AcpStructuredSessionAdapterDeps = { now?: () => number mintGeneration?: () => string mintLinkId?: () => string - /** Bounds a Stop's wait for the agent to end its turn; past it the child is closed. */ + /** Bounds a cancel's wait for the agent to answer its prompt; past it the connection closes. */ cancelTimeoutMs?: number + /** Bounds a Stop's wait for the agent to end its turn; past it the child is closed. */ + stopGraceMs?: number /** Bounds the handshake and session load or creation; past it the start fails. */ startupTimeoutMs?: number isWindowsProcessStartTimeAvailable?: () => boolean diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index f97f19e3fe7e..6ab89570b619 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -114,7 +114,7 @@ describe('ACP Stop names a turn', () => { }) describe('ACP Stop of a turn Grok began itself', () => { - it('cancels a turn opened by a background completion and waits for it to end', async () => { + it('cancels a turn opened by a background completion; the Stop waits for it to end', async () => { const rig = await openAcpAdapterRig() await rig.acquire() const { agent } = rig.child() @@ -131,26 +131,28 @@ describe('ACP Stop of a turn Grok began itself', () => { } }) ) + expect(rig.adapter.stopEndsSession()).toBe(true) await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ cancelled: true }) + await rig.adapter.awaitStoppedRequestEnd(SESSION, Date.now()) expect(rig.sent('session/cancel')).toHaveLength(1) expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'interrupted' }) + // The host ends the child next; the adapter does not end it on its own. expect(rig.child().closes).toBe(0) }) - it('closes the child when Grok never ends its own turn after the cancel', async () => { - const rig = await openAcpAdapterRig({ deps: { cancelTimeoutMs: 20 } }) + it("ends the Stop's wait at its grace when Grok never ends its own turn", async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 20 } }) await rig.acquire() rig.child().agent.notify('session/update', replyChunk('task-completed-background-1', 'Busy')) await rig.settle() await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ - cancelled: false + cancelled: true }) - await waitFor(() => expect(rig.child().closes).toBe(1)) - await waitFor(async () => - expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'unverifiable' }) - ) + await rig.adapter.awaitStoppedRequestEnd(SESSION, Date.now()) + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'running' }) + expect(rig.child().closes).toBe(0) }) }) diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 0bd185321d6e..cdd42d4b3b16 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -14,7 +14,6 @@ import { AgentSessionPreSpawnError, isAgentSessionPreSpawnError, type AgentSessionAcquisition, - type AgentSessionCancelOutcome, type AgentSessionDispatchOutcome, type StructuredAgentSessionAcquireInput, type StructuredAgentSessionAdapter, @@ -31,7 +30,7 @@ import { import { AcpStructuredStarts, type AcpStartAttempt } from './acp-structured-starts' import { acpPromptBlocks } from './acp-structured-turns' import { - ACP_CANCEL_TIMEOUT_MS, + ACP_STOP_GRACE_MS, type AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' @@ -154,57 +153,50 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = async (input) => { const session = this.live(input.sessionId) - // `session/cancel` stops whatever the session runs, so a Stop naming a turn that has since + // The Stop ends the child unless it is declined here, so a Stop naming a turn that has since // ended must stop nothing newer: not the turn running now, nor the follow-ups behind it. const liveTurnId = input.resolveLiveTurnId?.() ?? session.lane.openTurnId if (input.turnId !== undefined && input.turnId !== liveTurnId) { return { cancelled: false, refusal: { turnNotRunning: true } } } const withdrew = session.turns.withdrawQueued() - if (session.turns.running) { - try { - // Ends when the agent answers the prompt `cancelled`. The cancel aborts every open request - // first, each answered as the agent spells a cancellation. Past the bound the connection - // closes, which stops the child. - await session.runtime.cancel() - return { cancelled: true } - } catch { - return { cancelled: false } - } - } - const agentTurnId = session.lane.openTurnId - if (agentTurnId !== null) { - return this.cancelAgentTurn(session, agentTurnId) + if (!session.turns.running && session.lane.openTurnId === null) { + return withdrew + ? { cancelled: true } + : { cancelled: false, refusal: { turnNotRunning: true } } } - return withdrew ? { cancelled: true } : { cancelled: false, refusal: { turnNotRunning: true } } + // Answers every open request cancelled and lets Grok end its turn its own way; the host ends + // the child once that lands or the grace runs out (`awaitStoppedRequestEnd`). + void session.runtime.cancel().catch(() => undefined) + return { cancelled: true } } - /** A turn the agent began itself, as it does when a background task finishes: no prompt of Orca's - * answers, so the turn's own end does, within the same bound a prompt's cancel has. */ - private async cancelAgentTurn( - session: AcpStructuredSession, - turnId: string - ): Promise { + // Stop is a session boundary: Grok's cancel ends only the running turn, and work it already moved + // to the background runs on and can begin a turn of its own. The next send resumes the session. + stopEndsSession = (): boolean => true + + awaitStoppedRequestEnd = async (sessionId: string, stoppedAt: number): Promise => { + const session = this.sessions.get(sessionId) + if (!session) { + return + } + const grace = this.deps.stopGraceMs ?? ACP_STOP_GRACE_MS let timer: ReturnType | undefined - const deadline = new Promise((resolve) => { - timer = setTimeout(() => resolve(false), this.deps.cancelTimeoutMs ?? ACP_CANCEL_TIMEOUT_MS) + const elapsed = new Promise((resolve) => { + timer = setTimeout(resolve, Math.max(0, stoppedAt + grace - Date.now())) }) + const turnId = session.lane.openTurnId try { - const left = session.lane.whenTurnLeaves(turnId).then(() => true) - await session.runtime.cancel() - if ((await Promise.race([left, deadline])) && session.journalClosed === null) { - return { cancelled: true } - } - } catch { - // The notification could not be written: the connection's own close ends the session. + await Promise.race([ + Promise.all([ + session.turns.whenIdle(), + turnId === null ? undefined : session.lane.whenTurnLeaves(turnId) + ]), + elapsed + ]) } finally { clearTimeout(timer) } - if (session.journalClosed === null) { - // The agent never ended its turn, so the child goes and the turn ends with it. - void this.forceCloseSession(session.sessionId) - } - return { cancelled: false } } answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index 7d83519d5324..c2d9bcba0ac9 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -58,6 +58,7 @@ export class AcpStructuredTurns { private active: Send | null = null private readonly queue: Send[] = [] private readonly unsettled = new Set() + private readonly idleWaiters = new Set<() => void>() private ended = false constructor(private readonly deps: AcpStructuredTurnsDeps) {} @@ -74,6 +75,14 @@ export class AcpStructuredTurns { ) } + /** Resolves once no prompt of Orca's is running. */ + whenIdle(): Promise { + if (!this.active) { + return Promise.resolve() + } + return new Promise((resolve) => this.idleWaiters.add(resolve)) + } + dispatch(send: Send): void { this.unsettled.add(send.clientMessageId) if (this.active) { @@ -113,6 +122,7 @@ export class AcpStructuredTurns { this.deps.settle({ clientMessageId: active.clientMessageId, state: 'unknown', reason }) } this.active = null + this.notifyIdle() } private start(send: Send): void { @@ -164,7 +174,16 @@ export class AcpStructuredTurns { const next = this.ended ? undefined : this.queue.shift() if (next) { this.start(next) + } else if (!this.active) { + this.notifyIdle() + } + } + + private notifyIdle(): void { + for (const wake of this.idleWaiters) { + wake() } + this.idleWaiters.clear() } private reject(clientMessageId: string, fact: SubmissionRejectionFact): void { From f6f9d668d9e40fbb4ba411466fe4426c9276c12f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:45:50 -0700 Subject: [PATCH 167/269] test(native-chat): a Grok Stop ends the process only after Grok answered the cancel --- src/main/acp/acp-structured-host-stop.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/acp/acp-structured-host-stop.test.ts b/src/main/acp/acp-structured-host-stop.test.ts index ea2e5a543c8f..adb0652e3c01 100644 --- a/src/main/acp/acp-structured-host-stop.test.ts +++ b/src/main/acp/acp-structured-host-stop.test.ts @@ -85,12 +85,25 @@ describe('a Grok chat Stop', () => { await send(host, 'hello') const prompt = await rig.frame('session/prompt') first.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'partial')) - first.agent.on('session/cancel', () => first.agent.reply(prompt, { stopReason: 'cancelled' })) + // Grok takes a moment to wind the turn down; the host waits for it before ending the process. + const answered: { cancel: boolean; atClose: boolean | null } = { cancel: false, atClose: null } + first.agent.on('session/cancel', () => + setTimeout(() => { + answered.cancel = true + first.agent.reply(prompt, { stopReason: 'cancelled' }) + }, 50) + ) + const close = first.close.bind(first) + first.close = async () => { + answered.atClose ??= answered.cancel + return close() + } await rig.settle() await host.flushStreamedEvents(SESSION) expect(await stop(host)).toMatchObject({ ok: true, value: { cancelled: true } }) await waitFor(() => expect(first.exited).toBe(true)) expect(methods(first, 'session/cancel')).toHaveLength(1) + expect(answered.atClose).toBe(true) expect((await turns()).at(-1)).toMatchObject({ state: 'interrupted' }) await send(host, 'again') From 2245710903c45822ad73e743140ccdaff6b6e077 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:50:51 -0700 Subject: [PATCH 168/269] fix(native-chat): Steer on a Grok card cancels the running prompt, then sends it A send that reached Grok while a prompt ran was held in the adapter until that turn ended: Steer on a queued card took the card out of the host's editable queue and meant 'send after this turn'. It now cancels the running prompt (session/cancel; the session stays) and sends as the next prompt once Grok answers the cancel, as the common pattern does; a steer behind another cancels it in turn, so the last one runs. The adapter holds a send only while that cancel lands, so its general held-send queue and its holdsDispatch report are gone (every send it holds has its turn open in the journal). An older client's mid-turn send takes the same path. capabilities.steering is unchanged and still unread. --- src/main/acp/acp-structured-host-stop.test.ts | 91 ++------ .../acp/acp-structured-host.test-support.ts | 63 ++++- ...ructured-session-adapter-lifecycle.test.ts | 8 +- .../acp-structured-session-adapter.test.ts | 14 -- .../acp/acp-structured-session-adapter.ts | 5 +- src/main/acp/acp-structured-steer.test.ts | 220 ++++++++++++++++++ src/main/acp/acp-structured-turns.ts | 50 ++-- 7 files changed, 330 insertions(+), 121 deletions(-) create mode 100644 src/main/acp/acp-structured-steer.test.ts diff --git a/src/main/acp/acp-structured-host-stop.test.ts b/src/main/acp/acp-structured-host-stop.test.ts index adb0652e3c01..506f92d9a283 100644 --- a/src/main/acp/acp-structured-host-stop.test.ts +++ b/src/main/acp/acp-structured-host-stop.test.ts @@ -2,85 +2,28 @@ // next send resumes the session. import { afterEach, describe, expect, it } from 'vitest' -import { z } from 'zod' -import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' -import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { withNativeChatCutTurnNotices } from '../../shared/native-chat-cut-turn-notice' import { closeProviderTimelineRigs, providerTurnId } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' -import { - CALLER, - envelope -} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import type { FakeFrame } from './acp-scripted-agent.test-support' +import { PROVIDER_SESSION, replyChunk, waitFor } from './acp-structured-adapter.test-support' import { - GROK_CONFIG_OPTIONS, - PROVIDER_SESSION, - replyChunk, - waitFor, - type FakeAcpChild -} from './acp-structured-adapter.test-support' -import { attachParams, launch, openHostRig, RESUMES } from './acp-structured-host.test-support' + framesOf, + openAttachedHostRig, + promptIdOf, + send, + stop +} from './acp-structured-host.test-support' afterEach(async () => { await closeProviderTimelineRigs() }) -const promptMeta = z.object({ _meta: z.object({ promptId: z.string() }) }) -const promptIdOf = (frame: FakeFrame): string => promptMeta.parse(frame.params)._meta.promptId - -function message(text: string): AgentJournalMessageItem { - return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } -} - -async function send(host: StructuredAgentSessionHost, text: string): Promise { - const body = message(text) - const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - if (!sent.ok) { - throw new Error('send refused') - } - return sent.value.clientMessageId -} - -function stop(host: StructuredAgentSessionHost, turnId?: string) { - return turnId === undefined - ? host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) - : host.cancel(CALLER, { envelope: envelope('agentSession.cancel', { turnId }), turnId }) -} - -const methods = (child: FakeAcpChild, method: string) => - child.agent.frames.filter((frame) => frame.method === method) - -/** A Grok that resumes its session, counting each resume. */ -async function openResumingRig() { - const count = { resumes: 0 } - let resumed = false - const rig = await openHostRig({ - initialize: RESUMES, - script: (agent) => - agent.on('session/resume', (frame) => { - count.resumes += 1 - agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) - }), - deps: { resolveLaunch: launch(() => resumed) } - }) - expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) - resumed = true - const rows = async () => { - await rig.host.flushStreamedEvents(SESSION) - return (await rig.host.history({ sessionId: SESSION, direction: 'tail' })).page.items - } - const turns = async () => (await rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) - return { ...rig, count, rows, turns } -} - describe('a Grok chat Stop', () => { it('ends Grok once it settles the turn, and the next send resumes with no notice', async () => { - const { rig, host, count, rows, turns, messages } = await openResumingRig() + const { rig, host, count, rows, turns, messages } = await openAttachedHostRig() const first = rig.child() await send(host, 'hello') const prompt = await rig.frame('session/prompt') @@ -102,7 +45,7 @@ describe('a Grok chat Stop', () => { await host.flushStreamedEvents(SESSION) expect(await stop(host)).toMatchObject({ ok: true, value: { cancelled: true } }) await waitFor(() => expect(first.exited).toBe(true)) - expect(methods(first, 'session/cancel')).toHaveLength(1) + expect(framesOf(first, 'session/cancel')).toHaveLength(1) expect(answered.atClose).toBe(true) expect((await turns()).at(-1)).toMatchObject({ state: 'interrupted' }) @@ -111,7 +54,7 @@ describe('a Grok chat Stop', () => { const second = rig.child() await rig.frame('session/prompt') expect(count.resumes).toBe(1) - expect(methods(second, 'session/new')).toHaveLength(0) + expect(framesOf(second, 'session/new')).toHaveLength(0) expect(await messages()).toEqual(['hello', 'partial', 'again']) // Only the Stop's own note: no exit or cut-reply notice for a Stop the person asked for. const transcript = withNativeChatCutTurnNotices(await rows(), { agentName: 'Grok' }) @@ -122,7 +65,7 @@ describe('a Grok chat Stop', () => { }) it('ends Grok on a Stop of a turn it began itself, once that turn ends', async () => { - const { rig, host, turns } = await openResumingRig() + const { rig, host, turns } = await openAttachedHostRig() const { agent } = rig.child() agent.notify('session/update', replyChunk('task-completed-background-1', 'Working on it')) agent.on('session/cancel', () => @@ -139,13 +82,13 @@ describe('a Grok chat Stop', () => { expect((await turns()).at(-1)).toMatchObject({ state: 'running' }) expect(await stop(host)).toMatchObject({ ok: true, value: { cancelled: true } }) await waitFor(() => expect(rig.child().exited).toBe(true)) - expect(methods(rig.child(), 'session/cancel')).toHaveLength(1) + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(1) expect((await turns()).at(-1)).toMatchObject({ state: 'interrupted' }) await host.close(SESSION, 'user-close') }) it('leaves no Grok behind whose background work could begin a turn after the Stop', async () => { - const { rig, host, turns } = await openResumingRig() + const { rig, host, turns } = await openAttachedHostRig() const first = rig.child() await send(host, 'hello') const prompt = await rig.frame('session/prompt') @@ -167,7 +110,7 @@ describe('a Grok chat Stop', () => { describe('a Grok chat Stop naming a turn that has ended', () => { it('stops neither the turn running now nor Grok', async () => { - const { rig, host, turns } = await openResumingRig() + const { rig, host, turns } = await openAttachedHostRig() const { agent } = rig.child() await send(host, 'old') const old = await rig.frame('session/prompt') @@ -182,14 +125,14 @@ describe('a Grok chat Stop naming a turn that has ended', () => { const stopped = await stop(host, providerTurnId(promptIdOf(old), PROVIDER_SESSION)) expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) await rig.settle() - expect(methods(rig.child(), 'session/cancel')).toHaveLength(0) + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(0) expect(rig.child().exited).toBe(false) expect((await turns()).at(-1)).toMatchObject({ state: 'running' }) await host.close(SESSION, 'user-close') }) it('stops nothing in the gap before Grok echoes the next prompt', async () => { - const { rig, host } = await openResumingRig() + const { rig, host } = await openAttachedHostRig() const { agent } = rig.child() await send(host, 'old') const old = await rig.frame('session/prompt') @@ -202,7 +145,7 @@ describe('a Grok chat Stop naming a turn that has ended', () => { const stopped = await stop(host, providerTurnId(promptIdOf(old), PROVIDER_SESSION)) expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) await rig.settle() - expect(methods(rig.child(), 'session/cancel')).toHaveLength(0) + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(0) expect(rig.child().exited).toBe(false) await host.close(SESSION, 'user-close') }) diff --git a/src/main/acp/acp-structured-host.test-support.ts b/src/main/acp/acp-structured-host.test-support.ts index 42554c544f18..bd6dac1b387a 100644 --- a/src/main/acp/acp-structured-host.test-support.ts +++ b/src/main/acp/acp-structured-host.test-support.ts @@ -1,13 +1,18 @@ // The ACP adapter behind the real host, with a scripted Grok, the real record store and an // on-disk journal. +import { expect } from 'vitest' +import { z } from 'zod' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { agentSessionStoredAgents } from '../../shared/agent-session-stored-agent' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { messageText } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' import { + CALLER, + envelope, hostTestState, replaceHostTestState } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' @@ -18,12 +23,14 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { AgentSessionRecoveryCapsule } from '../runtime/agent-session-recovery-capsule' import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' -import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import type { AcpScriptedAgent, FakeFrame } from './acp-scripted-agent.test-support' import { GROK, + GROK_CONFIG_OPTIONS, openAcpAdapterRig, PROVIDER_SESSION, - replyChunk + replyChunk, + type FakeAcpChild } from './acp-structured-adapter.test-support' import { acpStructuredAgentDefinition } from './acp-structured-agent-definitions' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' @@ -68,12 +75,13 @@ export async function openHostRig( agents: agentSessionStoredAgents([{ agent: 'grok' }]) }) let generation = 0 - // As the runtime wires it: every exit the adapter observes reaches the host. + // As the runtime wires it: every exit and late send settlement the adapter observes reaches the host. const hosted: { host: StructuredAgentSessionHost | null } = { host: null } const rig = await openAcpAdapterRig({ ...options, deps: { onEvent: (event) => void hosted.host?.handleAdapterEvent(event), + onDispatchSettledLate: (settlement) => void hosted.host?.settleLateDispatch(settlement), now: () => HOST_TEST_NOW, readProcessStartTime: async () => 1_700_000_000_000 + ++generation, mintGeneration: () => `generation-${generation}`, @@ -128,3 +136,52 @@ export async function openHostRig( export const RESUMES = { agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } } } + +const promptMeta = z.object({ _meta: z.object({ promptId: z.string() }) }) +export const promptIdOf = (frame: FakeFrame): string => + promptMeta.parse(frame.params)._meta.promptId + +export function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +export async function send(host: StructuredAgentSessionHost, text: string): Promise { + const body = message(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error('send refused') + } + return sent.value.clientMessageId +} + +export function stop(host: StructuredAgentSessionHost, turnId?: string) { + return turnId === undefined + ? host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + : host.cancel(CALLER, { envelope: envelope('agentSession.cancel', { turnId }), turnId }) +} + +export const framesOf = (child: FakeAcpChild, method: string) => + child.agent.frames.filter((frame) => frame.method === method) + +/** A new Grok chat the host attached; Grok resumes its session on a later start, counting each. */ +export async function openAttachedHostRig(deps: Partial = {}) { + const count = { resumes: 0 } + let resumed = false + const rig = await openHostRig({ + initialize: RESUMES, + script: (agent) => + agent.on('session/resume', (frame) => { + count.resumes += 1 + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }), + deps: { resolveLaunch: launch(() => resumed), ...deps } + }) + expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + resumed = true + const rows = async () => { + await rig.host.flushStreamedEvents(SESSION) + return (await rig.host.history({ sessionId: SESSION, direction: 'tail' })).page.items + } + const turns = async () => (await rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + return { ...rig, count, rows, turns } +} diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index 6ab89570b619..0644eb687b41 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -28,17 +28,17 @@ async function journalTurns(rig: AcpAdapterRig) { const turnIdOf = (promptId: string) => providerTurnId(promptId, PROVIDER_SESSION) describe('ACP Stop names a turn', () => { - it('a Stop naming a turn that has ended stops neither the turn running now nor its follow-up', async () => { + it('a Stop naming a turn that has ended stops nothing running now', async () => { const rig = await openAcpAdapterRig() await rig.acquire() await sendHello(rig, 'old') const old = await rig.frame('session/prompt') rig.child().agent.notify('session/update', replyChunk('prompt:old', 'done')) rig.child().agent.reply(old, { stopReason: 'end_turn' }) + await rig.settle() await sendHello(rig, 'new') const current = await rig.frame('session/prompt', 1) rig.child().agent.notify('session/update', replyChunk('prompt:new', 'working')) - await sendHello(rig, 'follow-up') await rig.settle() rig .child() @@ -57,7 +57,6 @@ describe('ACP Stop names a turn', () => { await rig.settle() expect(rig.sent('session/cancel')).toHaveLength(0) expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['old', 'new']) - expect(rig.adapter.holdsDispatch(SESSION)).toBe(true) }) it('a Stop naming an ended turn during the gap before the next prompt opens stops nothing', async () => { @@ -67,6 +66,7 @@ describe('ACP Stop names a turn', () => { const old = await rig.frame('session/prompt') rig.child().agent.notify('session/update', replyChunk('prompt:old', 'done')) rig.child().agent.reply(old, { stopReason: 'end_turn' }) + await rig.settle() await sendHello(rig, 'new') await rig.frame('session/prompt', 1) await rig.settle() @@ -82,7 +82,7 @@ describe('ACP Stop names a turn', () => { expect(rig.sent('session/cancel')).toHaveLength(0) }) - it('a Stop naming the turn running now stops it and withdraws what is held behind it', async () => { + it('a Stop naming the turn running now stops it and withdraws the steer waiting on its cancel', async () => { const rig = await openAcpAdapterRig() await rig.acquire() await sendHello(rig, 'new') diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 685d844684e9..4446e0802014 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -140,20 +140,6 @@ describe('ACP structured session adapter: turns', () => { }) }) - it('holds a follow-up until the running prompt is answered, then sends it as the next prompt', async () => { - const rig = await openAcpAdapterRig() - await rig.acquire() - await send(rig, 'send-1') - const first = await rig.frame('session/prompt') - await send(rig, 'send-2') - expect(rig.adapter.holdsDispatch(SESSION)).toBe(true) - await rig.settle() - expect(rig.sent('session/prompt')).toHaveLength(1) - rig.child().agent.reply(first, { stopReason: 'end_turn' }) - const second = await rig.frame('session/prompt', 1) - expect(second.params).toMatchObject({ _meta: { promptId: 'prompt:send-2' } }) - }) - it('stops a running turn: open permissions answer cancelled, session/cancel goes out', async () => { const rig = await openAcpAdapterRig() await rig.acquire() diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index cdd42d4b3b16..87e3ddff4eb4 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -159,7 +159,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte if (input.turnId !== undefined && input.turnId !== liveTurnId) { return { cancelled: false, refusal: { turnNotRunning: true } } } - const withdrew = session.turns.withdrawQueued() + const withdrew = session.turns.withdrawSteers() if (!session.turns.running && session.lane.openTurnId === null) { return withdrew ? { cancelled: true } @@ -228,9 +228,6 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte readCommands = (sessionId: string) => this.sessions.get(sessionId)?.options.readCommands() - holdsDispatch = (sessionId: string): boolean => - this.sessions.get(sessionId)?.turns.holdsDispatch() ?? false - // ACP has no way to stop one background task the agent started. backgroundTaskStops: NonNullable = ( sessionId diff --git a/src/main/acp/acp-structured-steer.test.ts b/src/main/acp/acp-structured-steer.test.ts new file mode 100644 index 000000000000..ea738e10be56 --- /dev/null +++ b/src/main/acp/acp-structured-steer.test.ts @@ -0,0 +1,220 @@ +// A message sent while a Grok prompt runs steers: the running prompt is cancelled (the session +// stays) and the message goes as the next prompt. The adapter holds a steer only while that cancel +// lands; the host's queue holds everything else. + +import { afterEach, describe, expect, it } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { DISPATCH_REJECTED_CANCELLED } from '../../shared/structured-agent-session-dispatch-rejection' +import { + closeProviderTimelineRigs, + SESSION as ADAPTER_SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + CALLER, + envelope +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { AcpScriptedAgent, FakeFrame } from './acp-scripted-agent.test-support' +import { + openAcpAdapterRig, + replyChunk, + sendHello, + waitFor +} from './acp-structured-adapter.test-support' +import { + framesOf, + message, + openAttachedHostRig, + promptIdOf, + send, + stop +} from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +/** Grok answers each cancel by ending the prompt it is running, as `cancelled`. */ +function answersCancels(agent: AcpScriptedAgent): void { + agent.on('session/cancel', () => { + const answered = new Set(agent.frames.flatMap((frame) => (frame.method ? [] : [frame.id]))) + const running = agent.frames.findLast( + (frame): frame is FakeFrame => frame.method === 'session/prompt' && !answered.has(frame.id) + ) + if (running) { + agent.reply(running, { stopReason: 'cancelled' }) + } + }) +} + +describe('a send while a Grok prompt runs', () => { + it('cancels the running prompt, then sends as the next one', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + const first = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) + await rig.settle() + await sendHello(rig, 'steer') + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(1) + expect(rig.sent('session/prompt')).toHaveLength(1) + rig.child().agent.reply(first, { stopReason: 'cancelled' }) + const second = await rig.frame('session/prompt', 1) + expect(promptIdOf(second)).toBe('prompt:steer') + rig.child().agent.notify('session/update', replyChunk('prompt:steer', 'on it')) + await waitFor(async () => { + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.map((turn) => turn.state)).toEqual(['interrupted', 'running']) + }) + // A cancel, not a Stop: the session stays. + expect(rig.child().closes).toBe(0) + }) + + it('runs the last of two quick steers; the one between is sent and cancelled', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + await rig.frame('session/prompt') + await rig.settle() + await sendHello(rig, 'steer-1') + await sendHello(rig, 'steer-2') + await rig.settle() + // One cancel per running prompt, however many steers wait behind it. + expect(rig.sent('session/cancel')).toHaveLength(1) + answersCancels(rig.child().agent) + rig.child().agent.reply(rig.sent('session/prompt')[0]!, { stopReason: 'cancelled' }) + await rig.frame('session/prompt', 2) + await rig.settle() + expect(rig.sent('session/prompt').map(promptIdOf)).toEqual([ + 'prompt:first', + 'prompt:steer-1', + 'prompt:steer-2' + ]) + expect(rig.sent('session/cancel')).toHaveLength(2) + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['first', 'steer-1']) + }) + + it('withdraws a steer a Stop reaches before its cancel lands', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + const first = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) + await rig.settle() + await sendHello(rig, 'steer') + await expect(rig.adapter.cancelTurn({ sessionId: ADAPTER_SESSION, fence: 1 })).resolves.toEqual( + { + cancelled: true + } + ) + rig.child().agent.reply(first, { stopReason: 'cancelled' }) + await rig.settle() + expect(rig.sent('session/prompt')).toHaveLength(1) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'steer', state: 'rejected' }) + ]) + ) + }) +}) + +describe('Steer on a Grok card, through the host', () => { + /** A Grok turn running, and a card queued behind it. */ + async function cardBehindARunningTurn() { + const rig = await openAttachedHostRig({ stopGraceMs: 20 }) + const { host } = rig + await send(host, 'hello') + const prompt = await rig.rig.frame('session/prompt') + rig.rig.child().agent.notify('session/update', replyChunk(promptIdOf(prompt), 'working')) + await rig.rig.settle() + await host.flushStreamedEvents(SESSION) + const body = message('and then this') + const delivery = 'queue-if-active' as const + const queued = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body, delivery }), + body, + delivery, + userSend: true + }) + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued card') + } + const cardId = queued.value.queued.messageId + const sendNow = () => + host.queuedMessageSend(CALLER, { + envelope: envelope('agentSession.queuedMessageSend', { messageId: cardId }), + messageId: cardId + }) + const page = async () => { + await host.flushStreamedEvents(SESSION) + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) + return history.ok ? history.page : null + } + const sends = async () => + (await host.journalSnapshot(SESSION)).submissions + .filter((entry) => entry.queuedMessageId === cardId) + .map((entry) => ({ state: entry.dispatchState, reason: entry.reason })) + return { ...rig, prompt, cardId, sendNow, page, sends } + } + + it('leaves the card in the host queue while the turn runs, and cancels then sends it on Steer', async () => { + const { rig, host, prompt, sendNow, page, sends } = await cardBehindARunningTurn() + const { agent } = rig.child() + await rig.settle() + // Queued, not sent: nothing reaches Grok and the card stays editable. + expect((await page())?.queuedMessages?.map((card) => card.state)).toEqual(['waiting']) + expect(framesOf(rig.child(), 'session/prompt')).toHaveLength(1) + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(0) + + expect(await sendNow()).toMatchObject({ ok: true }) + await waitFor(() => expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(1)) + expect(framesOf(rig.child(), 'session/prompt')).toHaveLength(1) + agent.reply(prompt, { stopReason: 'cancelled' }) + const steered = await rig.frame('session/prompt', 1) + agent.notify('session/update', replyChunk(promptIdOf(steered), 'on it')) + await waitFor(async () => expect(await sends()).toMatchObject([{ state: 'accepted' }])) + expect((await page())?.queuedMessages ?? []).toEqual([]) + expect(rig.child().exited).toBe(false) + await host.close(SESSION, 'user-close') + }) + + it("steers an older client's send the same way", async () => { + const rig = await openAttachedHostRig() + const { agent } = rig.rig.child() + await send(rig.host, 'hello') + const prompt = await rig.rig.frame('session/prompt') + agent.notify('session/update', replyChunk(promptIdOf(prompt), 'working')) + await rig.rig.settle() + await rig.host.flushStreamedEvents(SESSION) + // No queued-message delivery: the host hands it over while the turn runs. + await send(rig.host, 'steer') + await waitFor(() => expect(framesOf(rig.rig.child(), 'session/cancel')).toHaveLength(1)) + agent.reply(prompt, { stopReason: 'cancelled' }) + await rig.rig.frame('session/prompt', 1) + await rig.host.close(SESSION, 'user-close') + }) + + it('brings the card back paused when a Stop reaches it before the cancel lands', async () => { + const { rig, host, sendNow, page, sends } = await cardBehindARunningTurn() + expect(await sendNow()).toMatchObject({ ok: true }) + await waitFor(() => expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(1)) + const first = rig.child() + expect(await stop(host)).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(first.exited).toBe(true)) + expect(framesOf(first, 'session/prompt')).toHaveLength(1) + await waitFor(async () => { + const current = await page() + expect({ + pause: current?.queuePause ?? null, + cards: (current?.queuedMessages ?? []).map((card) => card.state), + sends: await sends() + }).toEqual({ + pause: { reason: 'stopped' }, + cards: ['waiting'], + sends: [{ state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }] + }) + }) + await host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index c2d9bcba0ac9..b3e889586080 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -1,8 +1,10 @@ -// The sends of one ACP session. ACP runs one prompt at a time, so a message sent while a turn runs -// is held here and sent as the next prompt once the running one is answered. Each send is settled -// exactly once: accepted when the agent's first event for its turn (or its answer) arrives, -// rejected when the agent refused it or it never left Orca, unknown when the agent died with it or -// its connection broke before it answered. +// The sends of one ACP session. ACP runs one prompt at a time, so a message sent while a prompt runs +// steers: the running prompt is cancelled (the session stays) and the message goes as the next +// prompt once the agent answers the cancel. A steer waits here until then; one behind it cancels it +// in turn, so the last one runs, and a Stop withdraws what waits. Each send is settled exactly once: +// accepted when the agent's first event for its turn (or its answer) arrives, rejected when the +// agent refused it or it never left Orca, unknown when the agent died with it or its connection +// broke before it answered. import { agentSessionFailureFact, @@ -44,7 +46,7 @@ export function acpPromptBlocks(body: AgentJournalMessageItem): ContentBlock[] | type Send = { clientMessageId: string; prompt: ContentBlock[]; requestedAt: number } export type AcpStructuredTurnsDeps = { - runtime: Pick + runtime: Pick lane: AcpStructuredLane agentName: string now: () => number @@ -56,7 +58,8 @@ export type AcpStructuredTurnsDeps = { export class AcpStructuredTurns { private active: Send | null = null - private readonly queue: Send[] = [] + /** Steers waiting for the prompt ahead of them to answer its cancel. */ + private readonly steers: Send[] = [] private readonly unsettled = new Set() private readonly idleWaiters = new Set<() => void>() private ended = false @@ -67,14 +70,6 @@ export class AcpStructuredTurns { return this.active !== null } - /** A send the agent has neither answered nor started: one held here, or one it has not echoed. */ - holdsDispatch(): boolean { - return ( - this.queue.length > 0 || - (this.active !== null && this.unsettled.has(this.active.clientMessageId)) - ) - } - /** Resolves once no prompt of Orca's is running. */ whenIdle(): Promise { if (!this.active) { @@ -86,7 +81,8 @@ export class AcpStructuredTurns { dispatch(send: Send): void { this.unsettled.add(send.clientMessageId) if (this.active) { - this.queue.push(send) + this.steers.push(send) + this.cancelForSteer() return } this.start(send) @@ -102,9 +98,9 @@ export class AcpStructuredTurns { } } - /** A Stop: held sends never reach the agent. */ - withdrawQueued(): boolean { - const withdrawn = this.queue.splice(0) + /** A Stop: held steers never reach the agent. */ + withdrawSteers(): boolean { + const withdrawn = this.steers.splice(0) for (const send of withdrawn) { this.reject(send.clientMessageId, agentSessionFailureFact('cancelled')) } @@ -114,7 +110,7 @@ export class AcpStructuredTurns { /** The child is gone: held sends never left Orca, and the running one's fate is unknown. */ end(reason: string): void { this.ended = true - for (const send of this.queue.splice(0)) { + for (const send of this.steers.splice(0)) { this.reject(send.clientMessageId, agentSessionFailureFact('providerExited')) } const active = this.active @@ -132,7 +128,11 @@ export class AcpStructuredTurns { lane.apply(opened.events) // The agent echoes this id on every event of the turn, so its rows join the turn Orca opened. const meta = { promptId: opened.promptId, requestId: opened.promptId } - this.deps.runtime.prompt(send.prompt, meta).then( + const answered = this.deps.runtime.prompt(send.prompt, meta) + if (this.steers.length > 0) { + this.cancelForSteer() + } + answered.then( (result) => { if (this.active !== send) { return @@ -171,7 +171,7 @@ export class AcpStructuredTurns { if (this.active === send) { this.active = null } - const next = this.ended ? undefined : this.queue.shift() + const next = this.ended ? undefined : this.steers.shift() if (next) { this.start(next) } else if (!this.active) { @@ -179,6 +179,12 @@ export class AcpStructuredTurns { } } + /** Answers the agent's open requests cancelled and ends the running prompt. One the agent never + * answers closes the connection past the runtime's bound, which ends the session. */ + private cancelForSteer(): void { + void this.deps.runtime.cancel().catch(() => undefined) + } + private notifyIdle(): void { for (const wake of this.idleWaiters) { wake() From bca0c779ad082834ab8239565d9fd56485f3f266 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:59:41 -0700 Subject: [PATCH 169/269] refactor(native-chat): a close or Stop cancels a start through the acquire's own abort signal The host owns the acquire it runs, so it now owns its cancellation: each attach's acquire gets an AbortSignal, aborted from outside the session's queue by a close and by a Stop admitted now (the same admission rule as before). The optional abandonStart adapter hook, the router's fan-out to every adapter and the ACP adapter's session-keyed start map are gone; the ACP adapter keeps an unkeyed set of starts only so quit can prove their children gone, and keeps a failed start's unproven child until its exit is proven. The hook also let a later close ask that child again. The host now does that from state it holds: a close of a chat with no live child whose record still names an owner process with no death evidence asks the adapter to release it. The answer is not recorded as proof (the lease probe does that), so an owner pid an earlier Orca left is never killed or marked gone. Claude and Codex ignore the signal and hold no such child; their release is a no-op (tested). --- src/main/acp/acp-structured-acquire.ts | 2 +- .../acp-structured-adapter.test-support.ts | 2 + ...ructured-session-adapter-lifecycle.test.ts | 22 +++++-- .../acp/acp-structured-session-adapter.ts | 42 +++++-------- src/main/acp/acp-structured-starts.ts | 61 +++++++++++-------- ...structured-agent-session-acquire-aborts.ts | 26 ++++++++ .../structured-agent-session-acquisition.ts | 1 + ...structured-agent-session-adapter-router.ts | 7 --- .../structured-agent-session-adapter.ts | 8 +-- .../structured-agent-session-attach-flow.ts | 2 + ...ured-agent-session-attach-orchestration.ts | 3 + ...agent-session-close-abandons-start.test.ts | 14 ++--- ...agent-session-close-unproven-owner.test.ts | 48 +++++++++++++++ ...red-agent-session-conversation-lifetime.ts | 30 ++++++--- ...structured-agent-session-host-mutations.ts | 16 ++--- ...ctured-agent-session-host-runtime-state.ts | 2 + .../structured-agent-session-host.ts | 1 + ...ructured-agent-session-mutation-context.ts | 2 + 18 files changed, 191 insertions(+), 98 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-close-unproven-owner.test.ts diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 7f321473cf4f..9059e8f5cbfb 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -60,7 +60,7 @@ export async function acquireAcpStructuredSession(input: { acquire: StructuredAgentSessionAcquireInput deps: AcpStructuredSessionAdapterDeps generation: string - /** A close reached this start; checked until the spawn, after which `track` hands it the child. */ + /** The acquire was aborted; checked until the spawn, after which `track` hands it the child. */ abandoned: () => boolean /** Registers the child so a close during the acquire can stop it. */ track: (child: AcpStructuredChild) => void diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index 6c277e32bddc..2f38ad6c92fe 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -104,6 +104,7 @@ export type AcpAdapterRig = { acquire(options?: { fence?: number onSpawned?: () => Promise + signal?: AbortSignal }): ReturnType /** Frames Orca wrote to the agent with this method. */ sent(method: string): FakeFrame[] @@ -191,6 +192,7 @@ export async function openAcpAdapterRig( fence: acquireOptions.fence ?? 1, spawnToken: 'spawn-1', events: rig.eventSink, + ...(acquireOptions.signal ? { signal: acquireOptions.signal } : {}), onSpawned: async () => { spawned.push('onSpawned') await acquireOptions.onSpawned?.() diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index 0644eb687b41..3361145e7d0c 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -253,11 +253,12 @@ describe('ACP startup that never answers', () => { it('lets a close stop the child while the handshake is unanswered', async () => { const rig = await openAcpAdapterRig({ script: (agent) => agent.on('initialize', () => {}) }) const queue = new StructuredAgentSessionTaskQueue() - const acquiring = queue.serialize(SESSION, () => rig.acquire()) + const start = new AbortController() + const acquiring = queue.serialize(SESSION, () => rig.acquire({ signal: start.signal })) const failed = acquiring.catch((error: unknown) => error) await rig.frame('initialize') // What the host's close does outside the queue, then its queued stop. - await rig.adapter.abandonStart(SESSION) + start.abort() const closing = queue.serialize(SESSION, () => rig.adapter.closeSession(SESSION)) expect(rig.child().closes).toBe(1) expect(await failed).toMatchObject({ message: 'Grok was closed while starting' }) @@ -285,11 +286,12 @@ describe('ACP startup that never answers', () => { script: (agent) => agent.on('initialize', () => {}), deps: { startupTimeoutMs: 15 } }) - const failed = rig.acquire().catch((error: unknown) => error) + const start = new AbortController() + const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error) await rig.frame('initialize') const child = rig.child() child.close = vi.fn(async () => false) - await rig.adapter.abandonStart(SESSION) + start.abort() expect(await failed).toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) // Every later stop asks the child again, and none answers for it. expect(await rig.adapter.closeSession(SESSION)).toBe(false) @@ -322,9 +324,10 @@ describe('ACP startup that never answers', () => { } } }) - const failed = rig.acquire().catch((error: unknown) => error) + const start = new AbortController() + const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error) await tick() - await rig.adapter.abandonStart(SESSION) + start.abort() releaseLaunch() expect(await failed).toMatchObject({ name: 'AgentSessionPreSpawnError' }) expect(rig.spawned).toEqual([]) @@ -332,6 +335,13 @@ describe('ACP startup that never answers', () => { }) describe('ACP session release', () => { + it('stops nothing for a chat it never started, as one an earlier Orca left', async () => { + const rig = await openAcpAdapterRig() + await expect(rig.adapter.releaseAcquisition({ sessionId: SESSION })).resolves.toBe(true) + expect(rig.spawned).toEqual([]) + expect(rig.lifecycle).toEqual([]) + }) + it('keeps nothing of a chat once its child is proven closed', async () => { const rig = await openAcpAdapterRig() await rig.acquire() diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 87e3ddff4eb4..385b7c518d07 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -47,9 +47,9 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte async acquire(input: StructuredAgentSessionAcquireInput): Promise { const sessionId = input.identity.sessionId - const attempt = this.starts.begin(sessionId) + const attempt = this.starts.begin(input.signal) try { - if (!(await this.stopPrevious(sessionId))) { + if (!(await this.stop(sessionId))) { throw new AgentSessionAcquisitionExitUnprovenError( new Error( `the previous ${this.deps.spec.agent} child for ${sessionId} could not be stopped` @@ -58,7 +58,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } return await this.start(input, attempt) } finally { - this.starts.end(sessionId, attempt) + this.starts.end(attempt) } } @@ -73,7 +73,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte acquire: input, deps: this.deps, generation, - abandoned: () => attempt.abandoned, + abandoned: () => attempt.signal.aborted, track: (child) => this.starts.track(attempt, child), onExit: (session) => { if (session && this.sessions.get(sessionId) === session) { @@ -84,7 +84,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte onSettled: (settlement) => this.deps.onDispatchSettledLate?.({ sessionId, ...settlement }), forceClose: (id) => void this.forceCloseSession(id) }) - if (attempt.abandoned) { + if (attempt.signal.aborted) { session.lane.dispose() throw new Error('closed while starting') } @@ -93,12 +93,12 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } catch (error) { const { child } = attempt // Checked before the close below, which would make any exit look like one Orca asked for. - const exitedOnItsOwn = child?.exited === true && !attempt.abandoned + const exitedOnItsOwn = child?.exited === true && !attempt.signal.aborted if (child && !(await child.close().catch(() => false))) { this.starts.retainFailed(sessionId, child) throw new AgentSessionAcquisitionExitUnprovenError(error) } - if (attempt.abandoned) { + if (attempt.signal.aborted) { const closed = new Error( `${acpAgentName(this.deps.spec.agent)} was closed while starting`, { cause: error } @@ -115,13 +115,6 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } } - /** A close that must not wait behind an acquire still starting: that start stops now, its child - * if it has one, and the acquire fails through the start-failure surface. A failed start's - * child still unproven gone is asked again. */ - abandonStart = async (sessionId: string): Promise => { - await Promise.all([this.starts.abandon(sessionId), this.starts.stopFailed(sessionId)]) - } - async dispatch(input: { sessionId: string clientMessageId: string @@ -242,8 +235,11 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte forceCloseSession = (sessionId: string): Promise => this.stop(sessionId, false) async closeAll(): Promise { - const ids = new Set([...this.sessions.keys(), ...this.starts.sessionIds()]) - const proven = await Promise.all([...ids].map((sessionId) => this.stop(sessionId, true))) + const ids = new Set([...this.sessions.keys(), ...this.starts.failedSessionIds()]) + const proven = await Promise.all([ + this.starts.stopAll(), + ...[...ids].map((sessionId) => this.stop(sessionId, true)) + ]) if (proven.includes(false)) { throw new Error('an ACP agent child could not be proven stopped') } @@ -263,17 +259,9 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return closed } - /** True only once every child is proven gone, or when this adapter runs none for the session. */ - private async stop(sessionId: string, requested: boolean): Promise { - const [abandoned, previous] = await Promise.all([ - this.starts.abandon(sessionId), - this.stopPrevious(sessionId, requested) - ]) - return abandoned && previous - } - - /** What an earlier start left: a failed start's child, and the session's own. */ - private async stopPrevious(sessionId: string, requested = true): Promise { + /** True only once every child is proven gone, or when this adapter runs none for the session: + * a failed start's child, and the session's own. */ + private async stop(sessionId: string, requested = true): Promise { const [failedStart, session] = await Promise.all([ this.starts.stopFailed(sessionId), this.stopSession(sessionId, requested) diff --git a/src/main/acp/acp-structured-starts.ts b/src/main/acp/acp-structured-starts.ts index 983810bef0e6..3416dc0cd14f 100644 --- a/src/main/acp/acp-structured-starts.ts +++ b/src/main/acp/acp-structured-starts.ts @@ -1,44 +1,55 @@ -// The adapter's starts: each acquire from its first step, so a close reaches it at any point (before -// the spawn too), and each failed start's child until its exit is proven, so a later close retries -// it instead of answering for a child it no longer knows. +// The adapter's starts: each acquire under way, stopped when its signal aborts (a close or a Stop +// that must not wait behind it) or at quit, and each failed start's child until its exit is +// proven, so a later close retries it instead of answering for a child it no longer knows. import type { AcpStructuredChild } from './acp-structured-child' -export type AcpStartAttempt = { abandoned: boolean; child: AcpStructuredChild | null } +export type AcpStartAttempt = { + /** Aborted by the acquire's own signal, or by quit. */ + readonly signal: AbortSignal + child: AcpStructuredChild | null +} export class AcpStructuredStarts { - private readonly starting = new Map() + /** Each start under way, with what quit aborts it by. */ + private readonly starting = new Map() private readonly failed = new Map() - /** Registered before anything awaits, so a close from here on stops this start. */ - begin(sessionId: string): AcpStartAttempt { - const attempt: AcpStartAttempt = { abandoned: false, child: null } - this.starting.set(sessionId, attempt) + /** Registered before anything awaits, so an abort from here on stops this start. */ + begin(signal: AbortSignal | undefined): AcpStartAttempt { + const quit = new AbortController() + const attempt: AcpStartAttempt = { + signal: signal ? AbortSignal.any([signal, quit.signal]) : quit.signal, + child: null + } + attempt.signal.addEventListener('abort', () => void attempt.child?.close().catch(() => false), { + once: true + }) + this.starting.set(attempt, quit) return attempt } - /** The start has its child; one a close already reached goes as soon as it exists. */ + /** The start has its child; one already aborted goes as soon as it exists. */ track(attempt: AcpStartAttempt, child: AcpStructuredChild): void { attempt.child = child - if (attempt.abandoned) { + if (attempt.signal.aborted) { void child.close().catch(() => false) } } - end(sessionId: string, attempt: AcpStartAttempt): void { - if (this.starting.get(sessionId) === attempt) { - this.starting.delete(sessionId) - } + end(attempt: AcpStartAttempt): void { + this.starting.delete(attempt) } - /** The start under way, stopped: true once it has no child or that child's exit is proven. */ - async abandon(sessionId: string): Promise { - const attempt = this.starting.get(sessionId) - if (!attempt) { - return true - } - attempt.abandoned = true - return attempt.child ? attempt.child.close().catch(() => false) : true + /** Quit: every start under way stops; true once none has a child left unproven gone. */ + async stopAll(): Promise { + const proven = await Promise.all( + [...this.starting].map(([attempt, quit]) => { + quit.abort() + return attempt.child ? attempt.child.close().catch(() => false) : true + }) + ) + return !proven.includes(false) } /** A failed start whose child is not proven gone keeps it until its exit is. */ @@ -64,7 +75,7 @@ export class AcpStructuredStarts { return proven } - sessionIds(): string[] { - return [...this.starting.keys(), ...this.failed.keys()] + failedSessionIds(): string[] { + return [...this.failed.keys()] } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts new file mode 100644 index 000000000000..55ebd402a76b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts @@ -0,0 +1,26 @@ +// The acquire each session has in flight, owned by the host that runs it. The session's queue runs +// one attach at a time, so a session has at most one; a close, or a Stop admitted now, aborts it +// from outside the queue instead of waiting behind a start the provider may never answer. + +export class StructuredAgentSessionAcquireAborts { + private readonly inFlight = new Map() + + /** For the attach about to run; `end` once it settles. */ + begin(sessionId: string): { signal: AbortSignal; end: () => void } { + const controller = new AbortController() + this.inFlight.set(sessionId, controller) + return { + signal: controller.signal, + end: () => { + if (this.inFlight.get(sessionId) === controller) { + this.inFlight.delete(sessionId) + } + } + } + } + + /** A no-op when the session has nothing in flight. */ + abort(sessionId: string, reason: string): void { + this.inFlight.get(sessionId)?.abort(new Error(reason)) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index 6d6589521df7..25e7f6748688 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 @@ -40,6 +40,7 @@ export async function acquireOwner( ...(record.options ? { options: record.options } : {}), ...(input.eventSink ? { events: input.eventSink } : {}), ...(input.recordPhase ? { recordPhase: input.recordPhase } : {}), + ...(input.acquireSignal ? { signal: input.acquireSignal } : {}), onSpawned: async (process) => { record = await input.store.commitProcessIdentity({ sessionId: record.sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index fbaf78aeb802..689381b4ffff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -242,13 +242,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return this.closePromise } - /** No route exists until an acquire returns, so every adapter is asked. */ - abandonStart = async (sessionId: string): Promise => { - await Promise.all( - this.adapters().map((adapter) => adapter.abandonStart?.(sessionId) ?? Promise.resolve()) - ) - } - /** Drops a per-session stop receipt after the host releases its durable owner. */ acknowledgeSessionRelease = (sessionId: string): void => { this.routes.delete(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 60a73ed1f0ae..4e961f58e337 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -231,6 +231,10 @@ export type StructuredAgentSessionAcquireInput = { /** Durably records the child's identity the moment it exists, before any handshake, so a crash * mid-start leaves an owner recovery can stop. The acquisition's `process` must match it. */ onSpawned?: (process: AgentSessionProcessIdentity) => Promise + /** Aborted by a close, or by a Stop admitted now, that must not wait behind this acquire: the + * adapter stops what it started and the acquire fails. An adapter whose acquire never waits on + * the provider's handshake may ignore it. */ + signal?: AbortSignal } export type StructuredAgentSessionSetOptionInput = { @@ -382,8 +386,4 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & disposeSession?(sessionId: string): Promise /** Host acknowledgement that the proven-dead child, lease and journal owner are released. */ acknowledgeSessionRelease?(sessionId: string): void - /** A close that must not wait behind an acquire still in its handshake: stops that child now, so - * the acquire fails and the close queued behind it runs. Called outside the session's serialize; - * a no-op when nothing is starting. Absent where an acquire never waits on the provider. */ - abandonStart?(sessionId: string): Promise } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index fc6bb15a1f98..192e8ef10c59 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -61,6 +61,8 @@ export type AttachFlowInput = { params: AgentSessionAttachParams now: () => number recordPhase?: AgentSessionCreatePhaseRecorder + /** Aborted when a close, or a Stop admitted now, must not wait behind this attach's acquire. */ + acquireSignal?: AbortSignal /** Publishes the journal before clients can send against the new owner. `acquiredOwner` is * true only when this attach spawned the provider child, so a re-attach to a live one is not * mistaken for a cold acquire. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 85dabccda7fe..beec759b4bf7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -131,6 +131,7 @@ async function runAttach( candidate: null, committed: false } + const acquire = context.runtimeState.acquireAborts.begin(sessionId) try { const attached = await performAttach({ store: context.deps.store, @@ -157,6 +158,7 @@ async function runAttach( params, now: () => context.now(), recordPhase, + acquireSignal: acquire.signal, ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), openConversation: async (record) => { const conversation = await context.openConversation(record.sessionId, { @@ -227,6 +229,7 @@ async function runAttach( } return stampFailedCreateOwnerVerdict(context.deps.store, callerKey, params.envelope, attached) } finally { + acquire.end() if (!attempt.committed) { attemptSink.close() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-abandons-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-abandons-start.test.ts index 1f23110026f9..ee196dc5c896 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-abandons-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-abandons-start.test.ts @@ -21,20 +21,20 @@ import { claudeAndCodexDeclared } from './structured-agent-session-adapter-route it('a close stops a start the provider never answers instead of queueing behind it', async () => { const state = hostTestState() - let failStart: ((error: Error) => void) | undefined + let signal: AbortSignal | undefined + // A start the provider never answers ends only when its acquire is aborted. state.acquire.mockImplementation( - () => + (input) => new Promise((_resolve, reject) => { - failStart = reject + signal = input.signal + input.signal?.addEventListener('abort', () => reject(new Error('closed while starting'))) }) ) - // The provider's child stops, so its unanswered handshake fails the start. - const abandonStart = vi.fn(async () => failStart?.(new Error('closed while starting'))) const host = new StructuredAgentSessionHost({ agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store: state.store, - adapter: { ...adapter(), abandonStart }, + adapter: adapter(), journalDatabase: openTestJournalHostDatabase(state.root), recoveryCapsule: new AgentSessionRecoveryCapsule(state.root), claimKeyId: 'key-1', @@ -45,6 +45,6 @@ it('a close stops a start the provider never answers instead of queueing behind const attaching = host.attach(CALLER, attachParams()) await vi.waitFor(() => expect(state.acquire).toHaveBeenCalled()) await host.close(SESSION, 'user-close') - expect(abandonStart).toHaveBeenCalledWith(SESSION) + expect(signal?.aborted).toBe(true) expect((await attaching).ok).toBe(false) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-unproven-owner.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-unproven-owner.test.ts new file mode 100644 index 000000000000..363681080559 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-unproven-owner.test.ts @@ -0,0 +1,48 @@ +// A close re-asks the adapter for a failed start's owner the record still names +// (`releaseUnprovenOwner`). For Claude and Codex, which hold no such child, that ask stops nothing, +// spawns nothing and reports nothing: as after an Orca restart, where the record names an old pid. + +import { describe, expect, it, vi } from 'vitest' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { CodexStructuredSessionAdapter } from '../../codex/codex-structured-session-adapter' + +describe('a close re-asking for an owner the adapter never held', () => { + it('is a no-op for Claude', async () => { + const openConnection = vi.fn(async () => { + throw new Error('no Claude may start') + }) + const onEvent = vi.fn() + const persistHandle = vi.fn(async () => {}) + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => { + throw new Error('no Claude may launch') + }, + openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + onEvent, + persistHandle + }) + await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) + expect(openConnection).not.toHaveBeenCalled() + expect(onEvent).not.toHaveBeenCalled() + expect(persistHandle).not.toHaveBeenCalled() + }) + + it('is a no-op for Codex', async () => { + const openConnection = vi.fn(async () => { + throw new Error('no Codex may start') + }) + const onEvent = vi.fn() + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => { + throw new Error('no Codex may launch') + }, + openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + onEvent + }) + await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) + expect(openConnection).not.toHaveBeenCalled() + expect(onEvent).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 0be99ec1c893..1422400407ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -76,6 +76,25 @@ export function createStructuredAgentSessionConversationLifetime(host: { ) } + /** A start that failed with its child not proven gone leaves no child here, only the record's + * owner, so the close asks the adapter again; it stops only a child it still holds. Its answer + * proves nothing for the record: the lease probe does, and the close never waits on it. */ + const releaseUnprovenOwner = async (sessionId: string): Promise => { + const lease = deps().store.getRecord(sessionId)?.lease + if (sessions.get(sessionId)?.child || !lease?.ownerProcess || lease.deathEvidence !== null) { + return + } + await deps() + .adapter.releaseAcquisition?.({ sessionId }) + .catch((error: unknown) => + deps().logger.warn("stopping a failed start's agent for a close failed", { + scope: 'close-unproven-owner', + sessionId, + error + }) + ) + } + const idleSweep = new StructuredAgentSessionIdleSweep({ sessions, serialize, @@ -162,15 +181,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { * still queued will not be sent. */ close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => { // Outside the queue: a start the provider never answers must not hold the close behind it. - void deps() - .adapter.abandonStart?.(sessionId) - ?.catch((error: unknown) => - deps().logger.warn('stopping a starting provider for a close failed', { - scope: 'abandon-start', - sessionId, - error - }) - ) + host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') return serialize(sessionId, async () => { readRefusals.forget(sessionId) const session = sessions.get(sessionId) @@ -179,6 +190,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { await abandonQueuedStructuredAgentSessionMessages(deps(), sessionId, session.journal) } await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) + await releaseUnprovenOwner(sessionId) await closeConversation(sessionId) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 48372d3b8433..2ad22e6faecc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -110,7 +110,7 @@ export function cancelStructuredAgentSessionTurn( const plan = cancelPlan(params) const { prompt } = params if (!prompt && params.turnId === undefined) { - abandonStartForStop(context, caller, params.envelope, plan) + abortAcquireForStop(context, caller, params.envelope, plan) } // A card's Cancel stops whatever the chat has in flight, as the Stop button does; it reaches the // Stop only for a card the live turn raised (`cancelStructuredAgentSessionPrompt`). @@ -130,16 +130,14 @@ export function cancelStructuredAgentSessionTurn( * queue is waiting on stops now, as a close's does, and the Stop's own step then finds no child. * Only a Stop admission would run now; one naming a turn is about a child already gone, so it * leaves a newer start alone. */ -function abandonStartForStop( +function abortAcquireForStop( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, envelope: AgentSessionMutationEnvelope, plan: MutationPlan ): void { - const { adapter, store, logger } = context.deps - const { sessionId } = envelope + const { store } = context.deps if ( - !adapter.abandonStart || !agentSessionMutationAdmitsNow({ store, callerKey: caller.callerKey, @@ -150,13 +148,7 @@ function abandonStartForStop( ) { return } - void adapter.abandonStart(sessionId).catch((error: unknown) => - logger.warn('stopping a starting provider for a Stop failed', { - scope: 'abandon-start', - sessionId, - error - }) - ) + context.abortAcquire(envelope.sessionId, 'stopped while starting') } export function respondToStructuredAgentSessionPrompt( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts index fd0fd17b71e0..91f910de8ac3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -6,11 +6,13 @@ import { type StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' +import { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' import { resolveStructuredSessionRecovery } from './structured-agent-session-recovery-resolution' export class StructuredAgentSessionHostRuntimeState { private readonly eventSinks = new Map() + readonly acquireAborts = new StructuredAgentSessionAcquireAborts() private readonly leaseRenewer: StructuredAgentSessionLeaseRenewer private readonly onEventSinkFailure?: (sessionId: string, error: unknown) => void diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 175ccc958afa..9e883a8e5883 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -292,6 +292,7 @@ export class StructuredAgentSessionHost { wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), stopAgent: (sessionId, ending) => this.lifetime.stopAgent(sessionId, ending), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), + abortAcquire: (sessionId, reason) => this.runtimeState.acquireAborts.abort(sessionId, reason), now: () => this.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index fb12399c2370..41b2a8e523a8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -44,6 +44,8 @@ export type StructuredAgentSessionMutationContext = { * journal commit (a conversation command). Draft-table changes need no call: * the draft store notifies through the journal's own commit listener. */ wakeQueuedDrain?: (sessionId: string) => void + /** Aborts the acquire the session has in flight, from outside its serialize. */ + abortAcquire: (sessionId: string, reason: string) => void now: () => number } From a30a89ebf4c512d293bf1a0c5be3ffca20c1adc8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:07:47 -0700 Subject: [PATCH 170/269] fix(native-chat): a Grok crash that closes stdout before its exit still ends with Grok's last words On macOS and Linux the agent's stdout ends before its exit is observed, with or without the supervisor's EOF forwarding, so the connection's loss closed the journal first and its error text became the session's ended reason, dropping Grok's stderr. The reason is now read at the proven exit: the agent's last words when it left any, else why the connection closed. The failure already carried them. Comments that assumed the exit comes first, that early frames past the cap refuse the start, and that dispatch re-checks image support are corrected. --- src/main/acp/acp-structured-acquire.ts | 5 +++-- src/main/acp/acp-structured-agent-definitions.ts | 2 +- ...cp-structured-session-adapter-lifecycle.test.ts | 14 ++++++++++++++ src/main/acp/acp-structured-session.ts | 7 +++++-- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 9059e8f5cbfb..74ce113c20dd 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -39,7 +39,7 @@ import { RequestPermissionResponseSchema } from './generated/acp-protocol.genera /** ACP's "resource not found": the agent holds no session under the id this chat proved. */ const ACP_RESOURCE_NOT_FOUND = -32002 -/** Frames an agent may send before its session exists; past this the start is refused. */ +/** Frames an agent may send before its session exists; past this they are dropped. */ const MAX_EARLY_FRAMES = 2_048 /** How long the agent has to answer the handshake, reattach or create its session, and take saved * options back. A load-only agent replays the whole conversation, so this is generous. */ @@ -153,7 +153,8 @@ export async function acquireAcpStructuredSession(input: { whenLane(() => slot.lane && routeAcpSessionEvent({ lane: slot.lane, options }, event, now())) ) child.onExit(() => { - // The exit first, so the connection's close it causes is not read as one that broke. + // A crash usually ends stdout first, so the connection's loss may already have closed the + // journal; the session's end still reads the agent's last words at this proven exit. input.onExit(session) runtime.close(new Error(child.stderrTail() || `${spec.command} exited`)) }) diff --git a/src/main/acp/acp-structured-agent-definitions.ts b/src/main/acp/acp-structured-agent-definitions.ts index 1f0e8db4a5e3..d51816db450e 100644 --- a/src/main/acp/acp-structured-agent-definitions.ts +++ b/src/main/acp/acp-structured-agent-definitions.ts @@ -23,7 +23,7 @@ export function acpStructuredAgentDefinition(spec: AcpLaunchSpec): StructuredAge compact: false, threadGoal: false, contextUsage: true, - // Declared off until the agent's own initialize says otherwise; dispatch re-checks it. + // Off: Orca sends text prompts only until ACP image prompts have a path. imagePrompts: false, steering: 'queue', // Orca answers every permission request the agent sends; the agent decides when it asks. diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index 3361145e7d0c..aef0f89c7dce 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -182,6 +182,20 @@ describe('ACP connection loss', () => { await expect(sendHello(rig, 'after')).rejects.toThrow(/no live grok child/) }) + it("ends a crash whose stdout closed before its exit with Grok's last words", async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + rig.child().stderr = 'panic: out of memory' + rig.child().stdout.end() + await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) + expect(rig.lifecycle[0]).toMatchObject({ + type: 'ended', + cause: 'unexpected-exit', + reason: 'grok ACP agent exited: panic: out of memory', + failure: { kind: 'providerExited', detail: { text: 'panic: out of memory' } } + }) + }) + it('leaves a running turn unverifiable, never completed, when the stream breaks mid-turn', async () => { const rig = await openAcpAdapterRig() await rig.acquire() diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts index 27b177360ee3..d530998c2792 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -86,10 +86,13 @@ export function endAcpStructuredSession( session.ended = true session.exitObservedAt = observedAt const stderr = session.child.stderrTail() + // Read at the proven exit: a crash usually ends the agent's stdout before its exit is observed, + // so the connection's close comes first, but the agent's own last words are what explain it. const reason = session.closeRequested ? `${session.spec.agent} ACP agent closed by Orca` - : (session.journalClosed ?? - `${session.spec.agent} ACP agent exited${stderr ? `: ${stderr}` : ''}`) + : session.journalClosed !== null && !stderr + ? session.journalClosed + : `${session.spec.agent} ACP agent exited${stderr ? `: ${stderr}` : ''}` closeAcpSessionJournal(session, reason) const detail = stderr ? providerDiagnostic(stderr, 'person') : undefined onEvent?.({ From 5d68d91ce9018391cb71a772cfd84a37c1e65a52 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:09:35 -0700 Subject: [PATCH 171/269] fix(native-chat): nothing Grok sends while a held chat reattaches is written, marked as replay or not The reattach window relied on the dialect's replay verdict, and Grok's frames read as live unless they carry isReplay, so an unmarked chat frame during session/resume opened a turn that never ended. D3 now marks every frame inside the window as replay before the translator reads it, so the translator keeps only context usage whatever the agent marked; options and commands are still adopted. The translator's load semantics are unchanged. --- src/main/acp/acp-structured-acquire.ts | 31 ++++++++++++--- ...-structured-session-adapter-resume.test.ts | 39 +++++++++++++++++++ src/main/acp/acp-structured-session.ts | 27 +++++++++++-- 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 74ce113c20dd..9c579cdfae2d 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -3,7 +3,7 @@ // proved (`session/resume` where the agent offers it, else `session/load`) or start a new one. The // journal already holds a reattached chat, so whatever the agent sends while it reattaches is not // written, except context usage. The handshake is bounded: an agent that never answers fails the -// start instead of holding the chat's queue, and a close can stop the child at any point of it. +// start instead of holding the chat's queue, and the acquire's abort signal stops it at any point. import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' @@ -29,7 +29,11 @@ import { AcpStructuredLane } from './acp-structured-lane' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options' import { AcpStructuredPrompts } from './acp-structured-prompts' -import { routeAcpSessionEvent, type AcpStructuredSession } from './acp-structured-session' +import { + asReattachHistory, + routeAcpSessionEvent, + type AcpStructuredSession +} from './acp-structured-session' import { ACP_CANCEL_TIMEOUT_MS, type AcpStructuredSessionAdapterDeps @@ -108,7 +112,10 @@ export async function acquireAcpStructuredSession(input: { input.track(child) let session: AcpStructuredSession | null = null // A slot rather than a `let`: closures read it, and control-flow narrowing cannot see them write. - const slot: { lane: AcpStructuredLane | null } = { lane: null } + const slot: { lane: AcpStructuredLane | null; reattaching: boolean } = { + lane: null, + reattaching: false + } const options = new AcpStructuredOptions() const prompts = new AcpStructuredPrompts(() => slot.lane) const early: (() => void)[] = [] @@ -137,7 +144,15 @@ export async function acquireAcpStructuredSession(input: { }, onRequest: (method, params, context) => prompts.handle(method, params, context), onExtensionNotification: (method, params) => - whenLane(() => slot.lane?.apply(slot.lane.translator.notification(method, params, now()))), + whenLane(() => + slot.lane?.apply( + slot.lane.translator.notification( + method, + slot.reattaching ? asReattachHistory(params) : params, + now() + ) + ) + ), onDiagnostic: (message) => deps.logger?.warn('ACP agent protocol diagnostic', { scope: 'acp-diagnostic', @@ -150,7 +165,11 @@ export async function acquireAcpStructuredSession(input: { } }) runtime.subscribe((event: AcpSessionEvent) => - whenLane(() => slot.lane && routeAcpSessionEvent({ lane: slot.lane, options }, event, now())) + whenLane( + () => + slot.lane && + routeAcpSessionEvent({ lane: slot.lane, options }, event, now(), slot.reattaching) + ) ) child.onExit(() => { // A crash usually ends stdout first, so the connection's loss may already have closed the @@ -177,6 +196,7 @@ export async function acquireAcpStructuredSession(input: { onFailed: () => input.forceClose(sessionId) }) slot.lane = lane + slot.reattaching = attaching if (attaching) { lane.translator.beginLoad() } @@ -217,6 +237,7 @@ export async function acquireAcpStructuredSession(input: { sessionId: resume.sessionId, resumePreference: 'resume' }) + slot.reattaching = false attaching.apply(attaching.translator.finishLoad(now())) } catch (error) { const notFound = error instanceof AcpRpcError && error.code === ACP_RESOURCE_NOT_FOUND diff --git a/src/main/acp/acp-structured-session-adapter-resume.test.ts b/src/main/acp/acp-structured-session-adapter-resume.test.ts index a385fd3ffadb..0a3630e1f725 100644 --- a/src/main/acp/acp-structured-session-adapter-resume.test.ts +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -71,6 +71,45 @@ async function exchange(rig: AcpAdapterRig, reply: string, end: boolean): Promis const texts = async (rig: AcpAdapterRig) => (await rig.rig.rows()).flatMap((row) => messageText(row.body) ?? []) +/** What Grok might send while it resumes with no replay mark: an old reply and its turn's end. */ +function sendsUnmarkedWhileAttaching(agent: AcpScriptedAgent): void { + agent.notify('session/update', replyChunk('prompt:old', 'stale text')) + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'turn_completed', prompt_id: 'prompt:old', stop_reason: 'end_turn' } + }) +} + +describe('the reattach window', () => { + const resumesUnmarked = { + ...resume, + initialize: RESUMES, + script: (agent: AcpScriptedAgent) => + agent.on('session/resume', (frame) => { + sendsUnmarkedWhileAttaching(agent) + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + } + + it('writes nothing Grok sends while it resumes, even unmarked as replay', async () => { + const rig = await openAcpAdapterRig(resumesUnmarked) + await rig.acquire() + await rig.settle() + expect(await rig.rig.rows()).toEqual([]) + }) + + it('leaves no turn from the window for a later Stop to find', async () => { + const rig = await openAcpAdapterRig(resumesUnmarked) + await rig.acquire() + await rig.settle() + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: false, + refusal: { turnNotRunning: true } + }) + expect(rig.child().closes).toBe(0) + }) +}) + describe('reattaching a Grok chat the journal holds', () => { it('resumes with session/resume and writes nothing Grok sends while it resumes', async () => { const rig = await openAcpAdapterRig({ diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts index d530998c2792..64bb0e5d7cae 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -36,11 +36,12 @@ export type AcpStructuredSession = { } /** Every agent frame goes through here once the lane exists: options and commands first, so a - * read right after the frame sees them, then the journal. */ + * read right after the frame sees them, then the journal. `reattaching`: see `asReattachHistory`. */ export function routeAcpSessionEvent( session: Pick, event: AcpSessionEvent, - at: number + at: number, + reattaching = false ): void { if (event.kind === 'known') { const { update } = event.notification @@ -50,7 +51,27 @@ export function routeAcpSessionEvent( session.options.adoptConfigOptions(update.configOptions) } } - session.lane.apply(session.lane.translator.sessionEvent(event, at)) + const { translator } = session.lane + session.lane.apply( + reattaching + ? translator.notification( + 'session/update', + asReattachHistory(event.kind === 'known' ? event.notification : event.raw), + at + ) + : translator.sessionEvent(event, at) + ) +} + +/** While a chat the journal holds reattaches, everything the agent sends is history the journal + * already has, whatever the agent marked: marked as replay, the translator keeps only its usage. */ +export function asReattachHistory(params: unknown): unknown { + if (typeof params !== 'object' || params === null || Array.isArray(params)) { + return params + } + const meta = '_meta' in params ? params._meta : undefined + const marked = typeof meta === 'object' && meta !== null && !Array.isArray(meta) ? meta : {} + return { ...params, _meta: { ...marked, isReplay: true } } } /** From 586e6a667915649ee08a3dbb0bd1468a563e94ed Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:09:59 -0700 Subject: [PATCH 172/269] test(native-chat): a Stop after a resume finds no turn an unmarked old reply opened --- .../acp/acp-structured-session-adapter-resume.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/acp/acp-structured-session-adapter-resume.test.ts b/src/main/acp/acp-structured-session-adapter-resume.test.ts index 0a3630e1f725..0c78c944ac76 100644 --- a/src/main/acp/acp-structured-session-adapter-resume.test.ts +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -99,7 +99,15 @@ describe('the reattach window', () => { }) it('leaves no turn from the window for a later Stop to find', async () => { - const rig = await openAcpAdapterRig(resumesUnmarked) + const rig = await openAcpAdapterRig({ + ...resumesUnmarked, + script: (agent: AcpScriptedAgent) => + agent.on('session/resume', (frame) => { + // An old reply with no end and no replay mark. + agent.notify('session/update', replyChunk('prompt:old', 'stale text')) + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + }) await rig.acquire() await rig.settle() await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ From 651ab4710a7d7314a64f386bca29aaf8ef69d9cd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:11:20 -0700 Subject: [PATCH 173/269] test(native-chat): a resumed Grok chat keeps its last context reading; the resume refreshes only the window --- ...-structured-session-adapter-resume.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/main/acp/acp-structured-session-adapter-resume.test.ts b/src/main/acp/acp-structured-session-adapter-resume.test.ts index 0c78c944ac76..882a679426e4 100644 --- a/src/main/acp/acp-structured-session-adapter-resume.test.ts +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -118,6 +118,47 @@ describe('the reattach window', () => { }) }) +describe('the context meter across a resume', () => { + it('keeps the last reading the journal holds; the resume only refreshes the window', async () => { + const models = { + currentModelId: 'grok-4.7', + availableModels: [ + { modelId: 'grok-4.7', name: 'Grok 4.7', _meta: { totalContextTokens: 256_000 } } + ] + } + const rig = await openAcpAdapterRig({ + ...resume, + initialize: RESUMES, + script: (agent) => + // Grok replays no usage on resume; its answer carries only the models. + agent.on('session/resume', (frame) => + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS, models }) + ) + }) + await rig.acquire() + await sendHello(rig, 'm1') + const prompt = await rig.frame('session/prompt') + const { agent } = rig.child() + agent.notify('session/update', replyChunk('prompt:m1', 'hi')) + agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'usage_update', used: 4_000, size: 200_000 } + }) + agent.reply(prompt, { stopReason: 'end_turn' }) + await rig.settle() + await rig.adapter.closeSession(SESSION) + await rig.acquire({ fence: 2 }) + await rig.settle() + const usage = (await rig.rig.rows()) + .flatMap((row) => readAgentJournalTurn(row.body) ?? []) + .at(-1)?.contextUsage + expect(usage).toMatchObject({ + window: { tokens: 256_000 }, + used: { usage: { inputTokens: 4_000 } } + }) + }) +}) + describe('reattaching a Grok chat the journal holds', () => { it('resumes with session/resume and writes nothing Grok sends while it resumes', async () => { const rig = await openAcpAdapterRig({ From e8d1e11d493a1181c624a0a6b78c1db80c7e392b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:12:19 -0700 Subject: [PATCH 174/269] test(native-chat): a Grok background task a Stop ended reads as stopped reporting --- src/main/acp/acp-structured-host-stop.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/acp/acp-structured-host-stop.test.ts b/src/main/acp/acp-structured-host-stop.test.ts index 506f92d9a283..9658384d6ee5 100644 --- a/src/main/acp/acp-structured-host-stop.test.ts +++ b/src/main/acp/acp-structured-host-stop.test.ts @@ -88,16 +88,28 @@ describe('a Grok chat Stop', () => { }) it('leaves no Grok behind whose background work could begin a turn after the Stop', async () => { - const { rig, host, turns } = await openAttachedHostRig() + const { rig, host, rows, turns } = await openAttachedHostRig() const first = rig.child() await send(host, 'hello') const prompt = await rig.frame('session/prompt') first.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'started a build')) + first.agent.notify('x.ai/task_backgrounded', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'task_backgrounded', task_id: 'build-1', command: 'make all' } + }) first.agent.on('session/cancel', () => first.agent.reply(prompt, { stopReason: 'cancelled' })) await rig.settle() await host.flushStreamedEvents(SESSION) await stop(host) await waitFor(() => expect(first.exited).toBe(true)) + // The task ended with Grok's process; Orca cannot say how, so its row says no more than that. + const task = (await rows()).find((row) => row.itemId.includes('background-task')) + expect(task?.body).toMatchObject({ + blocks: [ + { type: 'text', text: 'Background command "make all" stopped reporting' }, + { type: 'background-task', taskId: 'build-1', state: 'unverifiable' } + ] + }) const settled = await turns() // What a kept process would do once its backgrounded build finished. first.agent.notify('session/update', replyChunk('task-completed-background-1', 'Build done')) From 294fd0de235664814249727edba6b9cdf0f2a4da Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:15:26 -0700 Subject: [PATCH 175/269] refactor(native-chat): quit's stop of each start answers through one promise kind --- src/main/acp/acp-structured-starts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/acp/acp-structured-starts.ts b/src/main/acp/acp-structured-starts.ts index 3416dc0cd14f..b1c117c4038f 100644 --- a/src/main/acp/acp-structured-starts.ts +++ b/src/main/acp/acp-structured-starts.ts @@ -44,7 +44,7 @@ export class AcpStructuredStarts { /** Quit: every start under way stops; true once none has a child left unproven gone. */ async stopAll(): Promise { const proven = await Promise.all( - [...this.starting].map(([attempt, quit]) => { + [...this.starting].map(async ([attempt, quit]) => { quit.abort() return attempt.child ? attempt.child.close().catch(() => false) : true }) From cd9a13814712e2e644f461180850009d6c2645c1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:41:12 -0700 Subject: [PATCH 176/269] fix(native-chat): quit aborts every start the host has in flight before draining attaches A Grok that never answered its handshake held quit until the start's own 60 s bound, past the 20 s quit deadline. The host's teardown now aborts each in-flight acquire (and any the drain still begins), so the adapter's own quit controller and its map of starts are gone: a start has one canceller, the host's signal. --- .../acp-structured-host-start-aborts.test.ts | 34 +++++++++++++++++++ .../acp/acp-structured-session-adapter.ts | 22 +++++------- src/main/acp/acp-structured-starts.ts | 30 +++------------- ...structured-agent-session-acquire-aborts.ts | 17 ++++++++-- ...ctured-agent-session-host-teardown.test.ts | 19 +++++++++-- .../structured-agent-session-host-teardown.ts | 6 ++++ 6 files changed, 83 insertions(+), 45 deletions(-) create mode 100644 src/main/acp/acp-structured-host-start-aborts.test.ts diff --git a/src/main/acp/acp-structured-host-start-aborts.test.ts b/src/main/acp/acp-structured-host-start-aborts.test.ts new file mode 100644 index 000000000000..5d63d019916e --- /dev/null +++ b/src/main/acp/acp-structured-host-start-aborts.test.ts @@ -0,0 +1,34 @@ +// A Grok start the host aborts — at quit, by a close, or by an admitted Stop — through the one +// signal the host owns, with a scripted Grok, the real record store and an on-disk journal. + +import { afterEach, describe, expect, it } from 'vitest' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { attachParams, openHostRig } from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const STALLED = Symbol('stalled') +const within = (promise: Promise, ms: number): Promise => + Promise.race([ + promise, + new Promise((resolve) => setTimeout(resolve, ms, STALLED)) + ]) + +describe('a Grok start the host aborts', () => { + it('quits promptly while Grok never answers its handshake, and stops the child', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}), + deps: { startupTimeoutMs: 60_000 } + }) + const attaching = host.attach(CALLER, attachParams()) + await rig.frame('initialize') + // Well inside the start's own bound: the quit is what ends it. + expect(await within(host.flushAllStreamedEvents({ trigger: 'quit' }), 2_000)).not.toBe(STALLED) + expect((await attaching).ok).toBe(false) + expect(rig.child().exited).toBe(true) + await rig.adapter.closeAll() + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 385b7c518d07..e2d2048a2a2f 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -48,18 +48,14 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte async acquire(input: StructuredAgentSessionAcquireInput): Promise { const sessionId = input.identity.sessionId const attempt = this.starts.begin(input.signal) - try { - if (!(await this.stop(sessionId))) { - throw new AgentSessionAcquisitionExitUnprovenError( - new Error( - `the previous ${this.deps.spec.agent} child for ${sessionId} could not be stopped` - ) + if (!(await this.stop(sessionId))) { + throw new AgentSessionAcquisitionExitUnprovenError( + new Error( + `the previous ${this.deps.spec.agent} child for ${sessionId} could not be stopped` ) - } - return await this.start(input, attempt) - } finally { - this.starts.end(attempt) + ) } + return await this.start(input, attempt) } private async start( @@ -236,10 +232,8 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte async closeAll(): Promise { const ids = new Set([...this.sessions.keys(), ...this.starts.failedSessionIds()]) - const proven = await Promise.all([ - this.starts.stopAll(), - ...[...ids].map((sessionId) => this.stop(sessionId, true)) - ]) + // A start still under way is the host's to abort: its teardown does, before this runs. + const proven = await Promise.all([...ids].map((sessionId) => this.stop(sessionId, true))) if (proven.includes(false)) { throw new Error('an ACP agent child could not be proven stopped') } diff --git a/src/main/acp/acp-structured-starts.ts b/src/main/acp/acp-structured-starts.ts index b1c117c4038f..5bf558ee7ba8 100644 --- a/src/main/acp/acp-structured-starts.ts +++ b/src/main/acp/acp-structured-starts.ts @@ -1,31 +1,24 @@ -// The adapter's starts: each acquire under way, stopped when its signal aborts (a close or a Stop -// that must not wait behind it) or at quit, and each failed start's child until its exit is +// The adapter's starts: each acquire under way, stopped when the host aborts its signal (a close, +// a Stop that must not wait behind it, or quit), and each failed start's child until its exit is // proven, so a later close retries it instead of answering for a child it no longer knows. import type { AcpStructuredChild } from './acp-structured-child' export type AcpStartAttempt = { - /** Aborted by the acquire's own signal, or by quit. */ + /** The host's: the start's one canceller. */ readonly signal: AbortSignal child: AcpStructuredChild | null } export class AcpStructuredStarts { - /** Each start under way, with what quit aborts it by. */ - private readonly starting = new Map() private readonly failed = new Map() /** Registered before anything awaits, so an abort from here on stops this start. */ begin(signal: AbortSignal | undefined): AcpStartAttempt { - const quit = new AbortController() - const attempt: AcpStartAttempt = { - signal: signal ? AbortSignal.any([signal, quit.signal]) : quit.signal, - child: null - } + const attempt: AcpStartAttempt = { signal: signal ?? new AbortController().signal, child: null } attempt.signal.addEventListener('abort', () => void attempt.child?.close().catch(() => false), { once: true }) - this.starting.set(attempt, quit) return attempt } @@ -37,21 +30,6 @@ export class AcpStructuredStarts { } } - end(attempt: AcpStartAttempt): void { - this.starting.delete(attempt) - } - - /** Quit: every start under way stops; true once none has a child left unproven gone. */ - async stopAll(): Promise { - const proven = await Promise.all( - [...this.starting].map(async ([attempt, quit]) => { - quit.abort() - return attempt.child ? attempt.child.close().catch(() => false) : true - }) - ) - return !proven.includes(false) - } - /** A failed start whose child is not proven gone keeps it until its exit is. */ retainFailed(sessionId: string, child: AcpStructuredChild): void { this.failed.set(sessionId, child) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts index 55ebd402a76b..d51d0d78cd41 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts @@ -1,13 +1,18 @@ // The acquire each session has in flight, owned by the host that runs it. The session's queue runs -// one attach at a time, so a session has at most one; a close, or a Stop admitted now, aborts it -// from outside the queue instead of waiting behind a start the provider may never answer. +// one attach at a time, so a session has at most one; a close, a Stop admitted now, or quit aborts +// it from outside the queue instead of waiting behind a start the provider may never answer. export class StructuredAgentSessionAcquireAborts { private readonly inFlight = new Map() + /** Set by quit: the host is going away, so an attach that begins after it starts aborted. */ + private quitReason: Error | null = null /** For the attach about to run; `end` once it settles. */ begin(sessionId: string): { signal: AbortSignal; end: () => void } { const controller = new AbortController() + if (this.quitReason) { + controller.abort(this.quitReason) + } this.inFlight.set(sessionId, controller) return { signal: controller.signal, @@ -23,4 +28,12 @@ export class StructuredAgentSessionAcquireAborts { abort(sessionId: string, reason: string): void { this.inFlight.get(sessionId)?.abort(new Error(reason)) } + + /** Quit: every start under way stops, and so does any the attach drain still runs. */ + abortAll(reason: string): void { + this.quitReason ??= new Error(reason) + for (const controller of this.inFlight.values()) { + controller.abort(this.quitReason) + } + } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts index dde5f82dae59..152d84a3bee6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts @@ -23,7 +23,11 @@ describe('structured agent-session host teardown', () => { const phases = structuredAgentSessionHostTeardownPhases({ logger: createStructuredAgentSessionLogger(), idleSweep: { dispose: noop }, - runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, + runtimeState: { + stopLeaseRenewal: () => undefined, + flushAllEventSinks: noop, + acquireAborts: { abortAll: () => {} } + }, tasks: { drainAttaches: noop }, evictOwnedSessions: noop, beginResumeMarkers: () => {}, @@ -33,6 +37,7 @@ describe('structured agent-session host teardown', () => { 'begin-resume-markers', 'dispose-idle-sweep', 'stop-lease-renewal', + 'abort-acquires', 'drain-attaches', 'evict-owned-sessions', 'record-resume-markers', @@ -67,7 +72,11 @@ describe('structured agent-session host teardown', () => { const evict = structuredAgentSessionHostTeardownPhases({ logger: createStructuredAgentSessionLogger(), idleSweep: { dispose: noop }, - runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, + runtimeState: { + stopLeaseRenewal: () => undefined, + flushAllEventSinks: noop, + acquireAborts: { abortAll: () => {} } + }, tasks: { drainAttaches: noop }, evictOwnedSessions: noop, beginResumeMarkers: () => {}, @@ -97,7 +106,11 @@ describe('structured agent-session host teardown', () => { const phases = structuredAgentSessionHostTeardownPhases({ logger: log.logger, idleSweep: { dispose: cleaned }, - runtimeState: { stopLeaseRenewal: () => {}, flushAllEventSinks: flush }, + runtimeState: { + stopLeaseRenewal: () => {}, + flushAllEventSinks: flush, + acquireAborts: { abortAll: () => {} } + }, tasks: { drainAttaches: cleaned }, evictOwnedSessions: cleaned, beginResumeMarkers: () => {}, 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 bbae1b4db163..1817f910ebc0 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 @@ -61,6 +61,7 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { runtimeState: { stopLeaseRenewal: () => Promise | void flushAllEventSinks: () => Promise + acquireAborts: { abortAll: (reason: string) => void } } tasks: { drainAttaches: () => Promise } evictOwnedSessions: () => Promise @@ -84,6 +85,11 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { }, { name: 'dispose-idle-sweep', run: () => collaborators.idleSweep.dispose() }, { name: 'stop-lease-renewal', run: () => collaborators.runtimeState.stopLeaseRenewal() }, + // Before the drain, which would otherwise wait out a start the provider never answers. + { + name: 'abort-acquires', + run: () => collaborators.runtimeState.acquireAborts.abortAll('closed by quit') + }, { name: 'drain-attaches', run: () => collaborators.tasks.drainAttaches() }, { name: 'evict-owned-sessions', From 2c2731da14dfb666f74667b3f126782a091e5e25 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:43:28 -0700 Subject: [PATCH 177/269] fix(native-chat): a Grok start's abort stops reaching its child once the start has returned The listener stayed on the host's signal until the attach finished committing, so a Close in that window killed the now-live child behind the host's back and it read as Grok crashing. The start now detaches it when it ends; a later Close goes through the session's own stop. --- .../acp-structured-host-start-aborts.test.ts | 43 +++++++++++++++++++ ...ructured-session-adapter-lifecycle.test.ts | 11 +++++ .../acp/acp-structured-session-adapter.ts | 16 ++++--- src/main/acp/acp-structured-starts.ts | 18 ++++++-- 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/src/main/acp/acp-structured-host-start-aborts.test.ts b/src/main/acp/acp-structured-host-start-aborts.test.ts index 5d63d019916e..f21c20fa77e7 100644 --- a/src/main/acp/acp-structured-host-start-aborts.test.ts +++ b/src/main/acp/acp-structured-host-start-aborts.test.ts @@ -3,7 +3,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { attachParams, openHostRig } from './acp-structured-host.test-support' afterEach(async () => { @@ -17,6 +19,26 @@ const within = (promise: Promise, ms: number): Promise new Promise((resolve) => setTimeout(resolve, ms, STALLED)) ]) +/** Runs `atEnd` once, right before the host ends an attach's controller: after its commit. */ +function beforeAttachEnds(host: StructuredAgentSessionHost, atEnd: () => void): void { + const aborts = host.collaboratorsForTests().runtimeState.acquireAborts + const begin = aborts.begin.bind(aborts) + let fired = false + aborts.begin = (sessionId) => { + const started = begin(sessionId) + return { + signal: started.signal, + end: () => { + if (!fired) { + fired = true + atEnd() + } + started.end() + } + } + } +} + describe('a Grok start the host aborts', () => { it('quits promptly while Grok never answers its handshake, and stops the child', async () => { const { rig, host } = await openHostRig({ @@ -31,4 +53,25 @@ describe('a Grok start the host aborts', () => { expect(rig.child().exited).toBe(true) await rig.adapter.closeAll() }) + + it('closes a chat whose start already returned as a close Orca asked for, not a crash', async () => { + const { rig, host, store } = await openHostRig() + const ended: unknown[] = [] + const handle = host.handleAdapterEvent.bind(host) + host.handleAdapterEvent = (event) => { + if (event.type === 'ended') { + ended.push(event) + } + return handle(event) + } + let closing: Promise | undefined + beforeAttachEnds(host, () => { + closing = host.close(SESSION, 'user-close') + }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await closing + expect(rig.child().exited).toBe(true) + expect(ended).toMatchObject([{ cause: 'requested-close' }]) + expect(store.getRecord(SESSION)?.lease.deathEvidence?.detail).not.toContain('exited') + }) }) diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index aef0f89c7dce..65dc80883d24 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -280,6 +280,17 @@ describe('ACP startup that never answers', () => { expect(rig.lifecycle).toEqual([]) }) + it('leaves the child of a start that returned alone when its signal aborts later', async () => { + const rig = await openAcpAdapterRig() + const start = new AbortController() + await rig.acquire({ signal: start.signal }) + // The host's close, landing while it still commits the attach, stops the child its own way. + start.abort(new Error('closed while starting')) + await rig.settle() + expect(rig.child().closes).toBe(0) + expect(rig.lifecycle).toEqual([]) + }) + it('fails the start once the handshake outlasts its bound, and stops the child', async () => { const rig = await openAcpAdapterRig({ script: (agent) => agent.on('session/new', () => {}), diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index e2d2048a2a2f..1b2860b2f02d 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -48,14 +48,18 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte async acquire(input: StructuredAgentSessionAcquireInput): Promise { const sessionId = input.identity.sessionId const attempt = this.starts.begin(input.signal) - if (!(await this.stop(sessionId))) { - throw new AgentSessionAcquisitionExitUnprovenError( - new Error( - `the previous ${this.deps.spec.agent} child for ${sessionId} could not be stopped` + try { + if (!(await this.stop(sessionId))) { + throw new AgentSessionAcquisitionExitUnprovenError( + new Error( + `the previous ${this.deps.spec.agent} child for ${sessionId} could not be stopped` + ) ) - ) + } + return await this.start(input, attempt) + } finally { + this.starts.end(attempt) } - return await this.start(input, attempt) } private async start( diff --git a/src/main/acp/acp-structured-starts.ts b/src/main/acp/acp-structured-starts.ts index 5bf558ee7ba8..b06578eacd42 100644 --- a/src/main/acp/acp-structured-starts.ts +++ b/src/main/acp/acp-structured-starts.ts @@ -8,6 +8,8 @@ export type AcpStartAttempt = { /** The host's: the start's one canceller. */ readonly signal: AbortSignal child: AcpStructuredChild | null + /** What the signal's abort does while the start runs; detached once it ends. */ + readonly stopChild: () => void } export class AcpStructuredStarts { @@ -15,10 +17,12 @@ export class AcpStructuredStarts { /** Registered before anything awaits, so an abort from here on stops this start. */ begin(signal: AbortSignal | undefined): AcpStartAttempt { - const attempt: AcpStartAttempt = { signal: signal ?? new AbortController().signal, child: null } - attempt.signal.addEventListener('abort', () => void attempt.child?.close().catch(() => false), { - once: true - }) + const attempt: AcpStartAttempt = { + signal: signal ?? new AbortController().signal, + child: null, + stopChild: () => void attempt.child?.close().catch(() => false) + } + attempt.signal.addEventListener('abort', attempt.stopChild, { once: true }) return attempt } @@ -30,6 +34,12 @@ export class AcpStructuredStarts { } } + /** A child the start handed over is the session's: an abort after this goes through its stop, + * which knows the close was asked for, not this listener, which would read as a crash. */ + end(attempt: AcpStartAttempt): void { + attempt.signal.removeEventListener('abort', attempt.stopChild) + } + /** A failed start whose child is not proven gone keeps it until its exit is. */ retainFailed(sessionId: string, child: AcpStructuredChild): void { this.failed.set(sessionId, child) From 418aa57baac733b6d77f195145aa87aa37997911 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:45:04 -0700 Subject: [PATCH 178/269] fix(native-chat): a close or Stop during any attach phase stops the start before it launches The attach began its abort controller only after reconciling leases, resolving recovery and probing the previous owner, so a close or admitted Stop in those phases reached nothing and Grok launched anyway. The controller now begins first, and the acquisition checks it before asking the adapter to start. --- .../acp-structured-host-start-aborts.test.ts | 53 ++++++++++++++++++- .../structured-agent-session-acquisition.ts | 2 + ...ured-agent-session-attach-orchestration.ts | 20 +++++-- 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/main/acp/acp-structured-host-start-aborts.test.ts b/src/main/acp/acp-structured-host-start-aborts.test.ts index f21c20fa77e7..7d5d081a2c27 100644 --- a/src/main/acp/acp-structured-host-start-aborts.test.ts +++ b/src/main/acp/acp-structured-host-start-aborts.test.ts @@ -6,7 +6,8 @@ import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { attachParams, openHostRig } from './acp-structured-host.test-support' +import { waitFor } from './acp-structured-adapter.test-support' +import { attachParams, openHostRig, send, stop } from './acp-structured-host.test-support' afterEach(async () => { await closeProviderTimelineRigs() @@ -39,6 +40,20 @@ function beforeAttachEnds(host: StructuredAgentSessionHost, atEnd: () => void): } } +/** Runs `during` once, while the attach is still probing the previous owner, before any launch. */ +function duringOwnerProbe(host: StructuredAgentSessionHost, during: () => void): void { + const state = host.collaboratorsForTests().runtimeState + const probe = state.probeOwner.bind(state) + let fired = false + state.probeOwner = async (sessionId) => { + if (!fired) { + fired = true + during() + } + return probe(sessionId) + } +} + describe('a Grok start the host aborts', () => { it('quits promptly while Grok never answers its handshake, and stops the child', async () => { const { rig, host } = await openHostRig({ @@ -74,4 +89,40 @@ describe('a Grok start the host aborts', () => { expect(ended).toMatchObject([{ cause: 'requested-close' }]) expect(store.getRecord(SESSION)?.lease.deathEvidence?.detail).not.toContain('exited') }) + + it('never launches Grok for a chat closed while its attach was still reconciling', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}), + deps: { startupTimeoutMs: 60_000 } + }) + let closing: Promise | undefined + duringOwnerProbe(host, () => { + closing = host.close(SESSION, 'user-close') + }) + expect( + await host.attach(CALLER, attachParams()).catch((error: unknown) => error) + ).toMatchObject({ name: 'AgentSessionPreSpawnError' }) + expect(await within(closing!, 2_000)).not.toBe(STALLED) + expect(rig.spawned).not.toContain('spawn') + }) + + it('never launches Grok for a start a Stop reached while its attach was still reconciling', async () => { + const { rig, host } = await openHostRig() + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + // The chat closes; the next send has to start Grok again. + await host.close(SESSION, 'user-close') + let stopping: ReturnType | undefined + duringOwnerProbe(host, () => { + stopping = stop(host) + }) + await send(host, 'hello') + await waitFor(() => expect(stopping).toBeDefined()) + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + await host.flushStreamedEvents(SESSION) + expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + expect(journal.submissions()).toMatchObject([ + { dispatchState: 'rejected', rejection: { kind: 'cancelled' } } + ]) + }) }) 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 25e7f6748688..e5180d0f05cb 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 @@ -29,6 +29,8 @@ export async function acquireOwner( try { try { await input.onAcquiring?.() + // A close or Stop that landed while the attach was still reconciling launches nothing. + input.acquireSignal?.throwIfAborted() } catch (error) { throw new AgentSessionPreSpawnError(error) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index beec759b4bf7..147964c6d3dd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -100,6 +100,22 @@ async function runAttach( callerKey: string, params: AgentSessionAttachParams, options: StructuredAgentSessionAttachOptions +): Promise> { + // Begun first, so a close or Stop during any phase below still stops the start. + const acquire = context.runtimeState.acquireAborts.begin(params.envelope.sessionId) + try { + return await runAttachUnderAbort(context, callerKey, params, options, acquire.signal) + } finally { + acquire.end() + } +} + +async function runAttachUnderAbort( + context: StructuredAgentSessionAttachContext, + callerKey: string, + params: AgentSessionAttachParams, + options: StructuredAgentSessionAttachOptions, + acquireSignal: AbortSignal ): Promise> { const sessionId = params.envelope.sessionId const recordPhase = options.recordPhase @@ -131,7 +147,6 @@ async function runAttach( candidate: null, committed: false } - const acquire = context.runtimeState.acquireAborts.begin(sessionId) try { const attached = await performAttach({ store: context.deps.store, @@ -158,7 +173,7 @@ async function runAttach( params, now: () => context.now(), recordPhase, - acquireSignal: acquire.signal, + acquireSignal, ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), openConversation: async (record) => { const conversation = await context.openConversation(record.sessionId, { @@ -229,7 +244,6 @@ async function runAttach( } return stampFailedCreateOwnerVerdict(context.deps.store, callerKey, params.envelope, attached) } finally { - acquire.end() if (!attempt.committed) { attemptSink.close() } From a0c5bbaca71ae1eb3c3668913abd060267404249 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:46:17 -0700 Subject: [PATCH 179/269] test(native-chat): a close during the attach's owner probe asks no adapter to start Also renames the close test after the hook it no longer exercises. --- ...-agent-session-close-aborts-start.test.ts} | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) rename src/main/native-chat/agent-session-wire/{structured-agent-session-close-abandons-start.test.ts => structured-agent-session-close-aborts-start.test.ts} (67%) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-abandons-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts similarity index 67% rename from src/main/native-chat/agent-session-wire/structured-agent-session-close-abandons-start.test.ts rename to src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts index ee196dc5c896..2823e9c5d651 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-abandons-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts @@ -1,5 +1,6 @@ // A close must not wait behind a start the provider never answers: the start runs under the -// session's queue, and the close's own stop is queued behind it. +// session's queue, and the close's own stop is queued behind it. The close aborts the start's +// signal instead, at whatever phase the attach is in. import { expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' @@ -19,17 +20,7 @@ import { import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' -it('a close stops a start the provider never answers instead of queueing behind it', async () => { - const state = hostTestState() - let signal: AbortSignal | undefined - // A start the provider never answers ends only when its acquire is aborted. - state.acquire.mockImplementation( - (input) => - new Promise((_resolve, reject) => { - signal = input.signal - input.signal?.addEventListener('abort', () => reject(new Error('closed while starting'))) - }) - ) +function openHost(state: ReturnType): StructuredAgentSessionHost { const host = new StructuredAgentSessionHost({ agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), @@ -42,9 +33,42 @@ it('a close stops a start the provider never answers instead of queueing behind now: () => HOST_TEST_NOW }) replaceHostTestState({ store: state.store, host }) + return host +} + +it('a close stops a start the provider never answers instead of queueing behind it', async () => { + const state = hostTestState() + let signal: AbortSignal | undefined + // A start the provider never answers ends only when its acquire is aborted. + state.acquire.mockImplementation( + (input) => + new Promise((_resolve, reject) => { + signal = input.signal + input.signal?.addEventListener('abort', () => reject(new Error('closed while starting'))) + }) + ) + const host = openHost(state) const attaching = host.attach(CALLER, attachParams()) await vi.waitFor(() => expect(state.acquire).toHaveBeenCalled()) await host.close(SESSION, 'user-close') expect(signal?.aborted).toBe(true) expect((await attaching).ok).toBe(false) }) + +it('a close while the attach still probes the previous owner asks no adapter to start', async () => { + const state = hostTestState() + // An adapter that never reads the signal: only the host's own check keeps it from starting. + const host = openHost(state) + const runtimeState = host.collaboratorsForTests().runtimeState + const probe = runtimeState.probeOwner.bind(runtimeState) + let closing: Promise | undefined + runtimeState.probeOwner = async (sessionId) => { + closing ??= host.close(SESSION, 'user-close') + return probe(sessionId) + } + expect(await host.attach(CALLER, attachParams()).catch((error: unknown) => error)).toMatchObject({ + name: 'AgentSessionPreSpawnError' + }) + await closing + expect(state.acquire).not.toHaveBeenCalled() +}) From b63c9b50eed550a4370aade09fc56d8e2d176c6e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:47:54 -0700 Subject: [PATCH 180/269] test(native-chat): a close's re-ask closes a Claude or Codex child a failed cleanup left The re-ask is not a no-op for them: when the adapter still holds the child its cleanup could not prove gone, the close stops it again as a requested close, and Claude persists the handle of the conversation it ran so the next send resumes it. Corrects the tests' and comment's wording; the close awaits the re-ask, bounded by each adapter's kill ladder. --- ...agent-session-close-unproven-owner.test.ts | 82 ++++++++++++++++++- ...red-agent-session-conversation-lifetime.ts | 5 +- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-unproven-owner.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-unproven-owner.test.ts index 363681080559..7536eb723c44 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-unproven-owner.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-unproven-owner.test.ts @@ -1,13 +1,28 @@ // A close re-asks the adapter for a failed start's owner the record still names -// (`releaseUnprovenOwner`). For Claude and Codex, which hold no such child, that ask stops nothing, -// spawns nothing and reports nothing: as after an Orca restart, where the record names an old pid. +// (`releaseUnprovenOwner`). An adapter acts only on a child it still holds in memory: one it holds +// nothing for stops nothing, spawns nothing and reports nothing (as after an Orca restart, where +// the record names an old pid); one still holding the child a failed cleanup left closes it again. import { describe, expect, it, vi } from 'vitest' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import type { ClaudeStructuredSessionEvent } from '../../claude/claude-structured-session-state' +import { + adapterFor as claudeAdapterFor, + fakeClaude, + identityFor as claudeIdentityFor, + recordingJournalSink +} from '../../claude/claude-structured-session-test-support' import { CodexStructuredSessionAdapter } from '../../codex/codex-structured-session-adapter' +import { + adapterFor as codexAdapterFor, + fakeCodex, + identityFor as codexIdentityFor +} from '../../codex/codex-structured-session-adapter-fixture' +import type { CodexStructuredSessionEvent } from '../../codex/codex-structured-session-state' +import { claudeAndCodexRouter } from './structured-agent-session-adapter-router-test-support' describe('a close re-asking for an owner the adapter never held', () => { - it('is a no-op for Claude', async () => { + it('stops nothing for Claude', async () => { const openConnection = vi.fn(async () => { throw new Error('no Claude may start') }) @@ -28,7 +43,7 @@ describe('a close re-asking for an owner the adapter never held', () => { expect(persistHandle).not.toHaveBeenCalled() }) - it('is a no-op for Codex', async () => { + it('stops nothing for Codex', async () => { const openConnection = vi.fn(async () => { throw new Error('no Codex may start') }) @@ -46,3 +61,62 @@ describe('a close re-asking for an owner the adapter never held', () => { expect(onEvent).not.toHaveBeenCalled() }) }) + +describe('a close re-asking for an owner whose failed cleanup the adapter still holds', () => { + it('closes the Claude child again, as a close Orca asked for, and keeps its handle', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const persisted: unknown[] = [] + const router = claudeAndCodexRouter( + { + claude: claudeAdapterFor(claude, {}, events, persisted), + // The router's fan-out asks it too; it holds nothing. + codex: codexAdapterFor(fakeCodex()) + }, + async () => {} + ) + await router.acquire({ + identity: claudeIdentityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: recordingJournalSink() + }) + const connection = claude.connections[0]! + const close = vi.spyOn(connection, 'close').mockResolvedValueOnce(false) + // The host could not commit the start, and its cleanup could not prove the child gone. + expect(await router.releaseAcquisition({ sessionId: 'session-1' }).catch(() => false)).toBe( + false + ) + const before = { events: events.length, persisted: persisted.length } + expect(await router.releaseAcquisition({ sessionId: 'session-1' })).toBe(true) + expect(close).toHaveBeenCalledTimes(2) + // The handle of the conversation the child ran, so the next send resumes it. + expect(events.slice(before.events).map((event) => event.type)).toEqual(['ended', 'handle']) + expect(events[before.events]).toMatchObject({ cause: 'requested-close' }) + expect(persisted).toHaveLength(before.persisted + 1) + }) + + it('closes the Codex child again, as a close Orca asked for', async () => { + const codex = fakeCodex() + const events: CodexStructuredSessionEvent[] = [] + const router = claudeAndCodexRouter( + { + claude: claudeAdapterFor(fakeClaude()), + codex: codexAdapterFor(codex, {}, events) + }, + async () => {} + ) + await router.acquire({ identity: codexIdentityFor('session-1'), fence: 7, spawnToken: 's-9' }) + const connection = codex.connections[0]! + const close = vi.spyOn(connection, 'close').mockResolvedValueOnce(false) + expect(await router.releaseAcquisition({ sessionId: 'session-1' }).catch(() => false)).toBe( + false + ) + const before = events.length + expect(await router.releaseAcquisition({ sessionId: 'session-1' })).toBe(true) + expect(close).toHaveBeenCalledTimes(2) + expect(events.slice(before).filter((event) => event.type === 'ended')).toMatchObject([ + { cause: 'requested-close' } + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 1422400407ab..51f1ee031201 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -77,8 +77,9 @@ export function createStructuredAgentSessionConversationLifetime(host: { } /** A start that failed with its child not proven gone leaves no child here, only the record's - * owner, so the close asks the adapter again; it stops only a child it still holds. Its answer - * proves nothing for the record: the lease probe does, and the close never waits on it. */ + * owner, so the close asks the adapter again; it stops only a child it still holds, for every + * agent. Its answer proves nothing for the record: the lease probe does. The close awaits it, + * bounded by the adapter's own kill ladder, and a failure is logged, never gating the close. */ const releaseUnprovenOwner = async (sessionId: string): Promise => { const lease = deps().store.getRecord(sessionId)?.lease if (sessions.get(sessionId)?.child || !lease?.ownerProcess || lease.deathEvidence !== null) { From 7a4ac2fe92e4829a51c09588e13489ff9a7036d4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:50:59 -0700 Subject: [PATCH 181/269] fix(native-chat): Steer during a turn Grok began itself cancels it and sends once it ends A send while Grok ran a turn of its own (a background task waking it) went straight to Grok, which queued it behind that turn where Orca could no longer withdraw it, while Stop treated the same turn as the running reply. The send now waits as a steer, the turn is cancelled once, and the message goes when the turn ends; a Stop withdraws it and an exit rejects it as never sent. --- src/main/acp/acp-structured-steer.test.ts | 96 +++++++++++++++++++++-- src/main/acp/acp-structured-turns.ts | 33 +++++++- 2 files changed, 118 insertions(+), 11 deletions(-) diff --git a/src/main/acp/acp-structured-steer.test.ts b/src/main/acp/acp-structured-steer.test.ts index ea738e10be56..0c4f90be6c65 100644 --- a/src/main/acp/acp-structured-steer.test.ts +++ b/src/main/acp/acp-structured-steer.test.ts @@ -17,6 +17,7 @@ import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/ import type { AcpScriptedAgent, FakeFrame } from './acp-scripted-agent.test-support' import { openAcpAdapterRig, + PROVIDER_SESSION, replyChunk, sendHello, waitFor @@ -34,6 +35,16 @@ afterEach(async () => { await closeProviderTimelineRigs() }) +/** A turn Grok began itself, as a background task's completion wakes it. */ +const GROK_TURN = 'task-completed-background-1' + +function endsGrokTurn(agent: AcpScriptedAgent): void { + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'turn_completed', prompt_id: GROK_TURN, stop_reason: 'cancelled' } + }) +} + /** Grok answers each cancel by ending the prompt it is running, as `cancelled`. */ function answersCancels(agent: AcpScriptedAgent): void { agent.on('session/cancel', () => { @@ -119,15 +130,55 @@ describe('a send while a Grok prompt runs', () => { }) }) +describe('a send while a turn Grok began itself runs', () => { + async function steerBehindGrokTurn() { + const rig = await openAcpAdapterRig() + await rig.acquire() + rig.child().agent.notify('session/update', replyChunk(GROK_TURN, 'Build finished; now')) + await rig.settle() + await sendHello(rig, 'steer') + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(1) + expect(rig.sent('session/prompt')).toHaveLength(0) + return rig + } + + it('withdraws the steer a Stop reaches while it waits for that turn to end', async () => { + const rig = await steerBehindGrokTurn() + await expect(rig.adapter.cancelTurn({ sessionId: ADAPTER_SESSION, fence: 1 })).resolves.toEqual( + { cancelled: true } + ) + endsGrokTurn(rig.child().agent) + await rig.settle() + expect(rig.sent('session/prompt')).toHaveLength(0) + expect(rig.settled).toEqual([ + expect.objectContaining({ + clientMessageId: 'steer', + state: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + ]) + }) + + it('rejects the waiting steer as never sent when Grok exits', async () => { + const rig = await steerBehindGrokTurn() + rig.child().exit() + await rig.settle() + expect(rig.sent('session/prompt')).toHaveLength(0) + expect(rig.settled).toEqual([ + expect.objectContaining({ + clientMessageId: 'steer', + state: 'rejected', + reason: 'Grok stopped before this message was sent.' + }) + ]) + }) +}) + describe('Steer on a Grok card, through the host', () => { - /** A Grok turn running, and a card queued behind it. */ - async function cardBehindARunningTurn() { - const rig = await openAttachedHostRig({ stopGraceMs: 20 }) + /** A card queued behind whatever turn runs. */ + async function queueCard(rig: Awaited>) { const { host } = rig - await send(host, 'hello') - const prompt = await rig.rig.frame('session/prompt') - rig.rig.child().agent.notify('session/update', replyChunk(promptIdOf(prompt), 'working')) - await rig.rig.settle() await host.flushStreamedEvents(SESSION) const body = message('and then this') const delivery = 'queue-if-active' as const @@ -155,7 +206,17 @@ describe('Steer on a Grok card, through the host', () => { (await host.journalSnapshot(SESSION)).submissions .filter((entry) => entry.queuedMessageId === cardId) .map((entry) => ({ state: entry.dispatchState, reason: entry.reason })) - return { ...rig, prompt, cardId, sendNow, page, sends } + return { cardId, sendNow, page, sends } + } + + /** A Grok turn running, and a card queued behind it. */ + async function cardBehindARunningTurn() { + const rig = await openAttachedHostRig({ stopGraceMs: 20 }) + await send(rig.host, 'hello') + const prompt = await rig.rig.frame('session/prompt') + rig.rig.child().agent.notify('session/update', replyChunk(promptIdOf(prompt), 'working')) + await rig.rig.settle() + return { ...rig, prompt, ...(await queueCard(rig)) } } it('leaves the card in the host queue while the turn runs, and cancels then sends it on Steer', async () => { @@ -217,4 +278,23 @@ describe('Steer on a Grok card, through the host', () => { }) await host.close(SESSION, 'user-close') }) + + it('cancels a turn Grok began itself on Steer, and sends the card once that turn ends', async () => { + const rig = await openAttachedHostRig({ stopGraceMs: 20 }) + const child = rig.rig.child() + child.agent.notify('session/update', replyChunk(GROK_TURN, 'Build finished; now')) + await rig.rig.settle() + const { sendNow, sends } = await queueCard(rig) + expect(await sendNow()).toMatchObject({ ok: true }) + await waitFor(() => expect(framesOf(child, 'session/cancel')).toHaveLength(1)) + // Not into Grok's own queue behind its turn, where Orca could no longer withdraw it. + expect(framesOf(child, 'session/prompt')).toHaveLength(0) + endsGrokTurn(child.agent) + const steered = await rig.rig.frame('session/prompt') + child.agent.notify('session/update', replyChunk(promptIdOf(steered), 'on it')) + await waitFor(async () => expect(await sends()).toMatchObject([{ state: 'accepted' }])) + expect(framesOf(child, 'session/cancel')).toHaveLength(1) + expect(child.exited).toBe(false) + await rig.host.close(SESSION, 'user-close') + }) }) diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index b3e889586080..abd9264a0b7b 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -1,7 +1,9 @@ // The sends of one ACP session. ACP runs one prompt at a time, so a message sent while a prompt runs // steers: the running prompt is cancelled (the session stays) and the message goes as the next -// prompt once the agent answers the cancel. A steer waits here until then; one behind it cancels it -// in turn, so the last one runs, and a Stop withdraws what waits. Each send is settled exactly once: +// prompt once the agent answers the cancel. A turn the agent began itself is the running reply too, +// as it is for a Stop: it is cancelled, and the message goes once that turn ends. A steer waits here +// until then; one behind it cancels it in turn, so the last one runs, and a Stop withdraws what +// waits. Each send is settled exactly once: // accepted when the agent's first event for its turn (or its answer) arrives, rejected when the // agent refused it or it never left Orca, unknown when the agent died with it or its connection // broke before it answered. @@ -58,7 +60,9 @@ export type AcpStructuredTurnsDeps = { export class AcpStructuredTurns { private active: Send | null = null - /** Steers waiting for the prompt ahead of them to answer its cancel. */ + /** The agent's own turn the steers wait to leave; null while none is waited on. */ + private awaitedTurn: string | null = null + /** Steers waiting for the prompt or turn ahead of them to answer its cancel. */ private readonly steers: Send[] = [] private readonly unsettled = new Set() private readonly idleWaiters = new Set<() => void>() @@ -85,6 +89,16 @@ export class AcpStructuredTurns { this.cancelForSteer() return } + if (this.awaitedTurn !== null) { + this.steers.push(send) + return + } + const theirs = this.deps.lane.openTurnId + if (theirs !== null) { + this.steers.push(send) + this.sendAfter(theirs) + return + } this.start(send) } @@ -179,6 +193,19 @@ export class AcpStructuredTurns { } } + /** Cancels the agent's own turn once, and sends the next steer when that turn ends. */ + private sendAfter(turnId: string): void { + this.awaitedTurn = turnId + this.cancelForSteer() + void this.deps.lane.whenTurnLeaves(turnId).then(() => { + this.awaitedTurn = null + const next = this.ended ? undefined : this.steers.shift() + if (next) { + this.start(next) + } + }) + } + /** Answers the agent's open requests cancelled and ends the running prompt. One the agent never * answers closes the connection past the runtime's bound, which ends the session. */ private cancelForSteer(): void { From 0ff2585a1600496cb764ab435c542e07922342cc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:51:48 -0700 Subject: [PATCH 182/269] test(native-chat): a steer whose cancel Grok never answers ends Grok and is rejected as never sent Pins the bounded steer cancel kept from the runtime: past the bound the connection closes, the running reply reads unverifiable, Grok's end reads as its exit, and the waiting steer is rejected as never sent. --- src/main/acp/acp-structured-steer.test.ts | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/main/acp/acp-structured-steer.test.ts b/src/main/acp/acp-structured-steer.test.ts index 0c4f90be6c65..c6dcf3580a00 100644 --- a/src/main/acp/acp-structured-steer.test.ts +++ b/src/main/acp/acp-structured-steer.test.ts @@ -128,6 +128,37 @@ describe('a send while a Grok prompt runs', () => { ]) ) }) + + // Unlike the common pattern, which waits on a steer's cancel without a bound: a Grok that ignores + // a cancel that long is hung, and its connection is not trusted further. + it('ends a Grok that never answers a steer cancel, and rejects the steer as never sent', async () => { + const rig = await openAcpAdapterRig({ deps: { cancelTimeoutMs: 30 } }) + await rig.acquire() + await sendHello(rig, 'first') + await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) + await rig.settle() + await sendHello(rig, 'steer') + await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) + expect(rig.lifecycle[0]).toMatchObject({ + type: 'ended', + cause: 'unexpected-exit', + failure: { kind: 'providerExited' }, + reason: expect.stringContaining('session/cancel') + }) + expect(rig.sent('session/prompt')).toHaveLength(1) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + clientMessageId: 'steer', + state: 'rejected', + reason: 'Grok stopped before this message was sent.' + }) + ]) + ) + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.map((turn) => turn.state)).toEqual(['unverifiable']) + }) }) describe('a send while a turn Grok began itself runs', () => { From 0baae440b1b56f883d7d7b227c43a3d98eda83b4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:54:36 -0700 Subject: [PATCH 183/269] fix(native-chat): a Grok crash stays a crash when a stop lands before its exit is proven After the connection broke and the close could not prove Grok's exit, any later stop Orca asked for (the next start, a Stop, a Close) marked the child as closed by Orca, so the crash read as a requested close and Grok's last words were dropped; a send meanwhile was recorded unconfirmed. The connection loss now decides the cause, and a send on that session is rejected as never sent. --- .../acp/acp-structured-host-lifecycle.test.ts | 59 ++++++++++++++++++- ...ructured-session-adapter-lifecycle.test.ts | 38 ++++++++++++ .../acp/acp-structured-session-adapter.ts | 29 ++++++--- 3 files changed, 117 insertions(+), 9 deletions(-) diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index 6a353e6f8daf..14e8ba6ff124 100644 --- a/src/main/acp/acp-structured-host-lifecycle.test.ts +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -23,8 +23,12 @@ import { attachParams, hello, launch, + openAttachedHostRig, openHostRig, - RESUMES + promptIdOf, + RESUMES, + send, + stop } from './acp-structured-host.test-support' afterEach(async () => { @@ -111,6 +115,59 @@ describe('resuming a Grok chat through the host', () => { }) }) +describe('a Grok crash whose exit is not proven yet', () => { + it('rejects a send meanwhile as never sent, and a later Stop does not turn the crash into one', async () => { + const { rig, host, rows } = await openAttachedHostRig() + const child = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + child.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'partial')) + await rig.settle() + let proves = false + child.close = async () => { + child.closes += 1 + if (proves) { + child.exit() + } + return proves + } + child.stderr = 'panic: out of memory' + child.stdout.end() + await rig.settle() + const next = await send(host, 'next') + await waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === next + ) + ).toMatchObject({ + dispatchState: 'rejected', + reason: 'Grok stopped before this message was sent.' + }) + ) + await stop(host) + proves = true + child.exit() + await rig.settle() + await host.flushStreamedEvents(SESSION) + const transcript = withNativeChatCutTurnNotices(await rows(), { agentName: 'Grok' }) + // The crash's own notice, as for any crash; not a Stop's. + expect( + transcript.flatMap((row) => + row.body.kind === 'status' + ? [{ text: row.body.text, failure: readAgentSessionFailureFact(row.body.failure)?.kind }] + : [] + ) + ).toEqual([ + { + text: 'Grok stopped while this response was in progress. You can continue in this conversation.', + failure: 'providerExited' + } + ]) + await host.close(SESSION, 'user-close') + }) +}) + describe('closing or stopping a Grok chat while it starts', () => { it('keeps a failed start whose child is not proven gone, so a later close asks it again', async () => { const { rig, host } = await openHostRig({ diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index 65dc80883d24..b5777d9b09be 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -196,6 +196,44 @@ describe('ACP connection loss', () => { }) }) + it('keeps a crash a crash when a stop Orca asks for lands before its unproven exit', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const child = rig.child() + child.close = async () => { + child.closes += 1 + return false + } + child.stderr = 'panic: late' + child.stdout.end() + await rig.settle() + expect(rig.lifecycle).toEqual([]) + // Never left Orca, so it is not recorded as unconfirmed. + expect( + await rig.adapter.dispatch({ + sessionId: SESSION, + clientMessageId: 'after', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'after' }] }, + fence: 1 + }) + ).toEqual({ + state: 'rejected', + reason: 'Grok stopped before this message was sent.', + rejection: { kind: 'providerExited' } + }) + // The next start's stop of the old child is one Orca asked for; it does not rename the crash. + await expect(rig.acquire()).rejects.toMatchObject({ + name: 'AgentSessionAcquisitionExitUnprovenError' + }) + child.exit() + await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) + expect(rig.lifecycle[0]).toMatchObject({ + cause: 'unexpected-exit', + reason: 'grok ACP agent exited: panic: late', + failure: { kind: 'providerExited', detail: { text: 'panic: late' } } + }) + }) + it('leaves a running turn unverifiable, never completed, when the stream breaks mid-turn', async () => { const rig = await openAcpAdapterRig() await rig.acquire() diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 1b2860b2f02d..1fe4ccd7d458 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -123,16 +123,15 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte requestedAt?: number beforeDispatch?: () => Promise }): Promise { + const lost = this.sessions.get(input.sessionId) + if (lost && lost.journalClosed !== null) { + // The connection broke and the exit is not yet proven: the message never left Orca. + return this.rejected(lost, 'providerExited') + } const session = this.live(input.sessionId) const prompt = acpPromptBlocks(input.body) if (!prompt) { - return { - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('attachmentInvalid'), { - surface: 'rejection', - agentName: acpAgentName(session.spec.agent) - }) - } + return this.rejected(session, 'attachmentInvalid') } await input.beforeDispatch?.() session.turns.dispatch({ @@ -272,8 +271,9 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte if (!session || session.ended) { return true } - session.closeRequested ||= requested + // A connection loss already decided why the child ends; a later stop does not relabel it. if (session.journalClosed === null) { + session.closeRequested ||= requested session.lane.flush() } const proven = await session.child.close() @@ -308,6 +308,19 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte void this.stop(session.sessionId, false) } + private rejected( + session: AcpStructuredSession, + kind: 'providerExited' | 'attachmentInvalid' + ): AgentSessionDispatchOutcome { + return { + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact(kind), { + surface: 'rejection', + agentName: acpAgentName(session.spec.agent) + }) + } + } + private live(sessionId: string): AcpStructuredSession { const session = this.sessions.get(sessionId) if (!session || session.journalClosed !== null) { From b0f1e33990b943ea57cd18f2ee349913674a32ba Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:58:33 -0700 Subject: [PATCH 184/269] test(native-chat): fixtures this PR's registered Grok and desktop capability made stale CI's unit shards failed on tests outside the PR's own lists. Each encodes something this PR changes on purpose: Grok is now a registered agent (the seam test's unregistered agent is now Cursor); the desktop now advertises registered agents (the restart-offer tests' older client drops that capability explicitly); the attach context carries the start's abort controllers (the forget-status double gains them); and the ACP real-host test rig sends to the host directly (listed beside the other real-host rig in the send ratchet). --- ...ctured-agent-session-forget-status.test.ts | 4 ++- ...structured-agent-registration-seam.test.ts | 8 +++--- .../send-agent-turn-boundary.test.ts | 3 ++- ...gent-session-restart-dismiss-fence.test.ts | 25 +++++++++++++------ ...tured-agent-session-restart-resume.test.ts | 8 +++--- 5 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index 912e54dc2e19..bb24e2503bdc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -20,6 +20,7 @@ import { createTrackedJournalOpener, openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -189,7 +190,8 @@ function attachContext( mintEventSink: () => eventSink, adoptEventSink: () => undefined, probeOwner: async () => ({ outcome: 'pid-absent' }), - discardEventSink: () => undefined + discardEventSink: () => undefined, + acquireAborts: new StructuredAgentSessionAcquireAborts() }, sessions, subscribers: { reset: () => undefined, snapshot: () => undefined, publish: () => undefined }, diff --git a/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts index 400ead611f36..69072c883573 100644 --- a/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts @@ -58,12 +58,14 @@ describe('the structured agent registration list', () => { it('refuses an agent it does not hold without installing the host', async () => { const { runtime, installHost } = runtimeAt() - - expect(await runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'grok')).toEqual({ + // No structured registration exists for Cursor. + expect( + await runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'cursor') + ).toEqual({ supported: false, reason: 'agent' }) - await expect(runtime.resolveStructuredAgentAccountHome('grok')).rejects.toMatchObject({ + await expect(runtime.resolveStructuredAgentAccountHome('cursor')).rejects.toMatchObject({ message: 'structured_agent_session_unsupported' }) expect(installHost).not.toHaveBeenCalled() diff --git a/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts b/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts index 334195a5c88e..af96d63063a5 100644 --- a/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts +++ b/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts @@ -155,8 +155,9 @@ describe('agent turn send boundary', () => { 'native-chat/agent-session-wire/structured-conversation-command-controller.ts', // The pointer lane's port, whose `send` is sendAgentTurn in structured-mailbox-pointer-host. 'runtime/orchestration/structured-mailbox-pointer-delivery.ts', - // A real-host test rig the shared scan does not count as a test file. + // Real-host test rigs the shared scan does not count as test files. 'native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts', + 'acp/acp-structured-host.test-support.ts', // An Electron WebContents IPC send, not a chat. 'browser/doc-preview-guest-policy.ts' ].sort() diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts index 9937a3eb3d89..2750b8e62267 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts @@ -1,7 +1,6 @@ // "Dismiss all" from the desktop prompt, through the context the desktop renderer really dispatches -// with: a paired runtime client without the registered-agents capability. On a host whose agents it -// all shows, the dismissal is the unscoped, fenced one; one that cannot show some agent leaves that -// agent's offers alone. +// with. On a host whose agents it all shows, the dismissal is the unscoped, fenced one; a desktop +// from before registered agents, which cannot show some agent, leaves that agent's offers alone. import { readFile } from 'node:fs/promises' import { join } from 'node:path' @@ -11,6 +10,7 @@ import { AgentSessionRecoveryCapsule } from '../../agent-session-recovery-capsule' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { CLAUDE_STRUCTURED_AGENT } from '../../../claude/claude-structured-agent-definition' import { CODEX_STRUCTURED_AGENT } from '../../../codex/codex-structured-agent-definition' import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -30,6 +30,12 @@ const DESKTOP = { clientKind: 'runtime' as const, clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] } +const OLDER_DESKTOP = { + ...DESKTOP, + clientCapabilities: DESKTOP.clientCapabilities.filter( + (capability) => capability !== STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) +} // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a registry reads only the methods a declaration needs; these declare none. const NO_METHODS = {} as StructuredAgentSessionAdapter @@ -41,7 +47,7 @@ const declaring = (definition: typeof CLAUDE_STRUCTURED_AGENT) => ({ adapter: NO_METHODS }) -/** This build's two agents plus one the desktop does not render yet. */ +/** Claude and Codex plus one agent an older desktop does not render. */ function withPilot(): StructuredAgentRegistry { return new StructuredAgentRegistry([ declaring(CLAUDE_STRUCTURED_AGENT), @@ -57,8 +63,8 @@ async function storedFence(root: string) { return dismissedAt ? { dismissedAt } : {} } -async function dismissAllFromDesktop() { - return call('agentSession.restartResumableDismiss', {}, DESKTOP) +async function dismissAllFromDesktop(client = DESKTOP) { + return call('agentSession.restartResumableDismiss', {}, client) } it('fences a dismissed offer against a late teardown write when the desktop sees every agent', async () => { @@ -79,7 +85,7 @@ it('fences a dismissed offer against a late teardown write when the desktop sees }) }) -it('leaves offers it was not shown when the host runs an agent the desktop cannot show', async () => { +it('leaves offers it was not shown when the host runs an agent an older desktop cannot show', async () => { const { host, root, marker } = await interruptedRestart( undefined, undefined, @@ -92,7 +98,10 @@ it('leaves offers it was not shown when the host runs an agent the desktop canno const unreadable = { ...marker!, sessionId: 'unreadable-session' } await capsule.record([unreadable], NOW) - expect(await dismissAllFromDesktop()).toMatchObject({ ok: true, result: { dismissed: 1 } }) + expect(await dismissAllFromDesktop(OLDER_DESKTOP)).toMatchObject({ + ok: true, + result: { dismissed: 1 } + }) expect(await capsule.list(NOW)).toEqual([unreadable]) // A scoped dismissal writes no fence: nothing it did not clear may be dropped later. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts index 8101473f894b..42b5a64bdb5d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts @@ -22,11 +22,13 @@ afterEach(clearStructuredHostStub) const CLAUDE_OFFER = { sessionId: 'claude-session', agent: 'claude' } const GROK_OFFER = { sessionId: 'grok-session', agent: 'grok' } -// What the desktop renderer really dispatches with: every client that calls these methods -// advertises Claude support. +// A desktop from before registered agents: every client that calls these methods advertises Claude +// support, and this one nothing beyond Claude and Codex. const OLD_CLIENT = { ...STRUCTURED_CLIENT, - clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES.filter( + (capability) => capability !== STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) } const NEW_CLIENT = { ...OLD_CLIENT, From 9931665767a20a6456a60becf26d7adab470b9cf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:03:42 -0700 Subject: [PATCH 185/269] fix(native-chat): a start quit stops is not the queued message's start failure With quit now aborting a start it would have waited for, the delivery step recorded the aborted start as the message's failure ("couldn't restart"). After quit has stopped delivery, the step leaves the message to quit, which settles it as a close does ("The chat closed before this message was sent."). The test that pinned quit waiting for that start and stopping its child now pins that nothing is launched behind quit. --- .../structured-agent-session-delivery-loop.ts | 6 ++++++ ...ured-agent-session-provider-child-record.test.ts | 13 ++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index e3aaa965edc9..5cf5759819f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -116,6 +116,12 @@ export class StructuredAgentSessionDeliveryLoop { return } if (!prepared.ok) { + if (this.disposed) { + // Quit aborts the start, so its refusal is no failure of the message: quit settles + // what is queued as a close does. + this.stop(sessionId) + return + } await this.deps.serialize(sessionId, () => this.fail(sessionId, this.refusedStart(sessionId, prepared)) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 6f0c544f377c..52157b331e90 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -632,7 +632,7 @@ describe('a quit with a message still queued', () => { }) }) - it('waits for the start already in flight and stops the child it produced (R2)', async () => { + it('stops a start already in flight before it launches a child (R2)', async () => { const starting = deferred() const closeSession = vi.fn(async () => true) adapterExtras = { closeSession } @@ -646,13 +646,20 @@ describe('a quit with a message still queued', () => { }) const id = await accept('hello') await eventually(() => expect(recovering).toHaveBeenCalled()) + const acquiresBefore = acquire.mock.calls.length const quit = host.flushAllStreamedEvents() starting.resolve() await quit - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) + // Quit aborts the start, so nothing is launched behind it and nothing is left to stop. + expect(acquire).toHaveBeenCalledTimes(acquiresBefore) + expect(closeSession).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + ownerProcess: null, + deathEvidence: { detail: 'reservation failed before spawn' } + }) expect(dispatch).not.toHaveBeenCalled() expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', From 7346f4181af115ded948cee1dd2c87fc5708a14a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:26:33 -0700 Subject: [PATCH 186/269] fix(native-chat): a message sent after a Stop or close aborted a start gets its own start A start the host aborts (an admitted Stop, a close, or quit) returned its refusal to the delivery loop, which then rejected whatever was queued at that moment with "couldn't restart", including a message the user sent after the Stop. The attach now reports that the host aborted it, and the loop re-derives from the journal instead: what the Stop or close withdrew is already settled, a message accepted since gets a start of its own, and quit's next step stops the loop. This replaces the quit-only carve-out with the same rule for every abort and every agent. --- .../acp-structured-host-start-aborts.test.ts | 85 ++++++++++++++++++- .../structured-agent-session-agent-start.ts | 20 ++++- ...ured-agent-session-attach-orchestration.ts | 5 ++ .../structured-agent-session-delivery-loop.ts | 9 +- ...ed-agent-session-stop-aborts-start.test.ts | 44 ++++++++++ 5 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts diff --git a/src/main/acp/acp-structured-host-start-aborts.test.ts b/src/main/acp/acp-structured-host-start-aborts.test.ts index 7d5d081a2c27..6617cc588248 100644 --- a/src/main/acp/acp-structured-host-start-aborts.test.ts +++ b/src/main/acp/acp-structured-host-start-aborts.test.ts @@ -6,8 +6,15 @@ import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { waitFor } from './acp-structured-adapter.test-support' -import { attachParams, openHostRig, send, stop } from './acp-structured-host.test-support' +import { GROK_CONFIG_OPTIONS, waitFor } from './acp-structured-adapter.test-support' +import { + attachParams, + launch, + openHostRig, + RESUMES, + send, + stop +} from './acp-structured-host.test-support' afterEach(async () => { await closeProviderTimelineRigs() @@ -125,4 +132,78 @@ describe('a Grok start the host aborts', () => { { dispatchState: 'rejected', rejection: { kind: 'cancelled' } } ]) }) + + it('starts Grok afresh for a message sent right after a Stop reached a reconciling start', async () => { + const { rig, host } = await openClosedResumableChat() + let stopping: ReturnType | undefined + let second: Promise | undefined + duringOwnerProbe(host, () => { + stopping = stop(host) + // Accepted while the aborted start is still unwinding. + second = send(host, 'second') + }) + await send(host, 'hello') + await waitFor(() => expect(second).toBeDefined()) + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + const secondId = await second! + await expectDeliveredAfterStop(host, secondId) + expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(2) + }) + + it('starts Grok afresh for a message sent right after a Stop ended a hung handshake', async () => { + const { rig, host, children } = await openClosedResumableChat({ hangsHandshake: 2 }) + const first = send(host, 'hello') + // The restart's own child, not the first one's. + await waitFor(() => expect(children()).toBe(2)) + await rig.frame('initialize') + const stopping = stop(host) + const second = send(host, 'second') + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + await first + await expectDeliveredAfterStop(host, await second) + expect(children()).toBe(3) + }) }) + +/** A Grok chat the user closed; each later start resumes it. `hangsHandshake`: that child never + * answers `initialize`. */ +async function openClosedResumableChat(options: { hangsHandshake?: number } = {}) { + let children = 0 + let resumed = false + const rig = await openHostRig({ + initialize: RESUMES, + script: (agent) => { + if (++children === options.hangsHandshake) { + agent.on('initialize', () => {}) + } + agent.on('session/resume', (frame) => + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + ) + }, + deps: { resolveLaunch: launch(() => resumed), startupTimeoutMs: 60_000 } + }) + expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + resumed = true + await rig.host.close(SESSION, 'user-close') + return { ...rig, children: () => children } +} + +/** The stopped message reads cancelled; the one sent after it reached Grok, with no failure row. */ +async function expectDeliveredAfterStop( + host: StructuredAgentSessionHost, + secondId: string +): Promise { + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + await waitFor(() => + expect( + journal.submissions().find((entry) => entry.clientMessageId === secondId)?.handedOverAt + ).toBeDefined() + ) + expect(journal.submissions().map((entry) => entry.rejection?.kind ?? null)).toEqual([ + 'cancelled', + null + ]) + await host.flushStreamedEvents(SESSION) + const rows = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + expect(rows.filter((row) => row.body.kind === 'status')).toEqual([]) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index b5a742b788bc..30a3a2bde7e2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -48,6 +48,8 @@ export type StructuredAgentSessionResumeOutcome = /** What the provider said about the failed start, for the chat's own record; host-side * only, never on the refusal. */ diagnostic?: ProviderDiagnostic + /** The host's own close, Stop or quit aborted the start, so it failed nothing it was for. */ + aborted?: true } /** The attach's caller key: the ledger row a start settles is Orca's own. */ @@ -162,11 +164,15 @@ async function startStructuredAgentSessionAgent( } let attached: AgentSessionMutationResult let acquisitionError: unknown + let aborted = false try { attached = await attachStructuredAgentSessionUnderSerialize(context, callerKey, params, { ...(startedFor === undefined ? {} : { startedFor }), onAcquisitionFailed: (error) => { acquisitionError = error + }, + onAborted: () => { + aborted = true } }) } catch (error) { @@ -181,19 +187,25 @@ async function startStructuredAgentSessionAgent( error ) if (settled) { - return withDiagnostic(settled.refusal, error) + return withDiagnostic(settled.refusal, error, aborted) } throw error } - return attached.ok ? { ok: true } : withDiagnostic(attached.refusal, acquisitionError) + return attached.ok ? { ok: true } : withDiagnostic(attached.refusal, acquisitionError, aborted) } function withDiagnostic( refusal: AgentSessionWireRefusal, - error: unknown + error: unknown, + aborted: boolean ): StructuredAgentSessionResumeOutcome { const diagnostic = providerDiagnosticOf(error) - return { ok: false, refusal, ...(diagnostic ? { diagnostic } : {}) } + return { + ok: false, + refusal, + ...(diagnostic ? { diagnostic } : {}), + ...(aborted ? { aborted: true as const } : {}) + } } function settledResumeRefusal( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 147964c6d3dd..de81c8255d24 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -47,6 +47,8 @@ export type StructuredAgentSessionAttachOptions = { onAcquisitionFailed?: AttachFlowInput['onAcquisitionFailed'] /** The queued message a start is for; see `StructuredAgentSessionProviderChild.startedFor`. */ startedFor?: string + /** A close, an admitted Stop or quit aborted this attach: its refusal is that abort's. */ + onAborted?: () => void } /** @@ -107,6 +109,9 @@ async function runAttach( return await runAttachUnderAbort(context, callerKey, params, options, acquire.signal) } finally { acquire.end() + if (acquire.signal.aborted) { + options.onAborted?.() + } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 5cf5759819f9..e438a4825381 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -116,11 +116,10 @@ export class StructuredAgentSessionDeliveryLoop { return } if (!prepared.ok) { - if (this.disposed) { - // Quit aborts the start, so its refusal is no failure of the message: quit settles - // what is queued as a close does. - this.stop(sessionId) - return + if (prepared.aborted) { + // A close, Stop or quit stopped this start on purpose, and settled what it was for; a + // message accepted since gets its own start, and quit's next step stops the loop. + continue } await this.deps.serialize(sessionId, () => this.fail(sessionId, this.refusedStart(sessionId, prepared)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts new file mode 100644 index 000000000000..d0eb9f953f02 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts @@ -0,0 +1,44 @@ +// A Stop the host admits aborts the start the queue is waiting on, for any agent: the host checks +// the start's signal before it asks the adapter. A message sent after that Stop is no part of the +// aborted start, so it gets a start of its own rather than that start's refusal. + +import { afterEach, expect, it } from 'vitest' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +it('starts a Codex chat afresh for a message sent right after a Stop reached its reconciling start', async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) + // The chat's child is gone; the next send has to start one. + await rig.host.close(SESSION, 'evict') + const runtimeState = rig.host.collaboratorsForTests().runtimeState + const probe = runtimeState.probeOwner.bind(runtimeState) + let stopping: ReturnType | undefined + let second: ReturnType | undefined + runtimeState.probeOwner = async (sessionId) => { + if (!stopping) { + stopping = rig.stop() + // Accepted while the aborted start is still unwinding. + second = rig.send('second') + } + return probe(sessionId) + } + const first = rig.send('hello') + await first.result + await eventually(() => expect(second).toBeDefined()) + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + await second!.result + await eventually(async () => + expect((await rig.submission(second!.id))?.handedOverAt).toBeDefined() + ) + expect((await rig.submission(first.id))?.rejection).toMatchObject({ kind: 'cancelled' }) + expect((await rig.submission(second!.id))?.rejection).toBeUndefined() + expect(rig.dispatch).toHaveBeenCalledTimes(1) +}) From 77c16ec1cbec02eed445ba929b479ab3fe96d600 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:29:35 -0700 Subject: [PATCH 187/269] test(native-chat): the message sent after an aborted start is answered, so no settlement outlives the test --- .../acp/acp-structured-host-start-aborts.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/acp/acp-structured-host-start-aborts.test.ts b/src/main/acp/acp-structured-host-start-aborts.test.ts index 6617cc588248..a80e3b921d41 100644 --- a/src/main/acp/acp-structured-host-start-aborts.test.ts +++ b/src/main/acp/acp-structured-host-start-aborts.test.ts @@ -145,8 +145,7 @@ describe('a Grok start the host aborts', () => { await send(host, 'hello') await waitFor(() => expect(second).toBeDefined()) expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) - const secondId = await second! - await expectDeliveredAfterStop(host, secondId) + await expectDeliveredAfterStop({ rig, host }, await second!) expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(2) }) @@ -160,7 +159,7 @@ describe('a Grok start the host aborts', () => { const second = send(host, 'second') expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) await first - await expectDeliveredAfterStop(host, await second) + await expectDeliveredAfterStop({ rig, host }, await second) expect(children()).toBe(3) }) }) @@ -190,14 +189,17 @@ async function openClosedResumableChat(options: { hangsHandshake?: number } = {} /** The stopped message reads cancelled; the one sent after it reached Grok, with no failure row. */ async function expectDeliveredAfterStop( - host: StructuredAgentSessionHost, + { rig, host }: Pick>, 'rig' | 'host'>, secondId: string ): Promise { + const prompt = await rig.frame('session/prompt') + expect(JSON.stringify(prompt.params)).toContain('second') + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal await waitFor(() => expect( - journal.submissions().find((entry) => entry.clientMessageId === secondId)?.handedOverAt - ).toBeDefined() + journal.submissions().find((entry) => entry.clientMessageId === secondId)?.dispatchState + ).toBe('accepted') ) expect(journal.submissions().map((entry) => entry.rejection?.kind ?? null)).toEqual([ 'cancelled', From 75231a407ba8218ee12a2f21f3b0dc59a9d7b6a5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:04:12 -0700 Subject: [PATCH 188/269] fix(native-chat): a Grok model pick Grok never answers no longer holds Stop or Close The pick runs on the session's queue. It now registers in the host's out-of-queue abort registry beside a start, so a close, an admitted Stop or quit abandons it, and the ACP adapter bounds it at 30 s like Claude and Codex. A late answer is still adopted. --- .../acp-structured-host-option-hang.test.ts | 88 +++++++++++++++++++ src/main/acp/acp-structured-options.ts | 40 +++++++++ .../acp-structured-session-adapter-deps.ts | 4 + .../acp/acp-structured-session-adapter.ts | 15 ++-- ...structured-agent-session-acquire-aborts.ts | 7 +- .../structured-agent-session-adapter.ts | 3 + ...structured-agent-session-host-mutations.ts | 17 +++- .../structured-agent-session-host.ts | 1 + ...ructured-agent-session-mutation-context.ts | 2 + .../structured-agent-session-turns-options.ts | 6 +- 10 files changed, 167 insertions(+), 16 deletions(-) create mode 100644 src/main/acp/acp-structured-host-option-hang.test.ts diff --git a/src/main/acp/acp-structured-host-option-hang.test.ts b/src/main/acp/acp-structured-host-option-hang.test.ts new file mode 100644 index 000000000000..32f0a6df64f6 --- /dev/null +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -0,0 +1,88 @@ +// A model or effort pick Grok never answers, through the real host: it must not hold the chat's +// Stop or Close behind it, and on its own it fails at a bound instead of holding the chat forever. + +import { afterEach, describe, expect, it } from 'vitest' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + CALLER, + envelope +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { GROK_CONFIG_OPTIONS, waitFor } from './acp-structured-adapter.test-support' +import { framesOf, openAttachedHostRig, stop } from './acp-structured-host.test-support' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +function pick(host: StructuredAgentSessionHost, value: string) { + const fields = { key: 'model', value } + return host.setOption(CALLER, { envelope: envelope('agentSession.setOption', fields), ...fields }) +} + +/** Whether `promise` settled within `ms`, and how. */ +async function within(promise: Promise, ms: number) { + let timer: ReturnType | undefined + const late = new Promise<'pending'>((resolve) => { + timer = setTimeout(() => resolve('pending'), ms) + }) + try { + return await Promise.race([ + promise.then( + (value) => ({ settled: 'resolved' as const, value }), + (error: unknown) => ({ settled: 'rejected' as const, value: error }) + ), + late + ]) + } finally { + clearTimeout(timer) + } +} + +const failed = (outcome: Awaited>) => + outcome !== 'pending' && + (outcome.settled === 'rejected' || + (typeof outcome.value === 'object' && outcome.value !== null && 'ok' in outcome.value + ? outcome.value.ok === false + : false)) + +describe('a Grok model pick Grok never answers', () => { + it('does not hold a Stop behind it; the pick fails', async () => { + const { rig, host } = await openAttachedHostRig() + const picking = pick(host, 'grok-4.6') + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) + expect(await within(stop(host), 2_000)).toMatchObject({ settled: 'resolved' }) + expect(failed(await within(picking, 1_000))).toBe(true) + expect(await within(host.close(SESSION, 'user-close'), 2_000)).not.toBe('pending') + }) + + it('does not hold a Close behind it; the pick fails', async () => { + const { rig, host } = await openAttachedHostRig() + const picking = pick(host, 'grok-4.6') + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) + expect(await within(host.close(SESSION, 'user-close'), 2_000)).toMatchObject({ + settled: 'resolved' + }) + expect(failed(await within(picking, 1_000))).toBe(true) + }) + + it('fails at its bound, frees the chat, and keeps what Grok answers late', async () => { + const { rig, host } = await openAttachedHostRig({ optionWriteTimeoutMs: 100 }) + const picking = pick(host, 'grok-4.6') + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) + expect(failed(await within(picking, 2_000))).toBe(true) + // The chat's queue moved on: a later operation runs. + expect(await within(host.readOptions(SESSION), 1_000)).not.toBe('pending') + const [frame] = framesOf(rig.child(), 'session/set_config_option') + rig.child().agent.reply(frame!, { + configOptions: GROK_CONFIG_OPTIONS.map((option) => + option.id === 'model' ? { ...option, currentValue: 'grok-4.6' } : option + ) + }) + await waitFor(async () => + expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' }) + ) + await host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index f91561589e76..751ab76276fa 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -158,6 +158,46 @@ export class AcpStructuredOptions { } } +/** Sends a pick and adopts the agent's answer, even one that lands after the wait gave up: that is + * still what the agent runs. The wait fails at `timeoutMs`, or once `signal` aborts. */ +export function writeAcpSessionOption( + runtime: Pick, + options: AcpStructuredOptions, + write: AcpOptionWrite, + bound: { agent: string; timeoutMs: number; signal?: AbortSignal } +): Promise { + const { agent, timeoutMs, signal } = bound + if (signal?.aborted) { + return Promise.reject(new Error(`${agent} option write abandoned before it was sent`)) + } + const applied = + write.method === 'config' + ? runtime + .setConfigOption(write.configId, write.value) + .then((result) => options.adoptConfigOptions(result.configOptions)) + : runtime.setModel(write.modelId).then(() => options.adoptModel(write.modelId)) + return new Promise((resolve, reject) => { + const fail = (error: Error): void => { + settle() + reject(error) + } + const onAbort = (): void => fail(new Error(`${agent} option write abandoned`)) + const timer = setTimeout( + () => fail(new Error(`${agent} did not answer an option write within ${timeoutMs}ms`)), + timeoutMs + ) + const settle = (): void => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + } + signal?.addEventListener('abort', onAbort, { once: true }) + applied.then(() => { + settle() + resolve() + }, fail) + }) +} + /** Re-applies the chat's saved picks to the agent's new session; a pick it refuses is skipped and * reported, never retried. */ export async function restoreAcpSessionOptions( diff --git a/src/main/acp/acp-structured-session-adapter-deps.ts b/src/main/acp/acp-structured-session-adapter-deps.ts index 79239bf3ebcb..03498de5e1c8 100644 --- a/src/main/acp/acp-structured-session-adapter-deps.ts +++ b/src/main/acp/acp-structured-session-adapter-deps.ts @@ -13,6 +13,8 @@ import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' export const ACP_CANCEL_TIMEOUT_MS = 10_000 /** How long a Stop, counted from its cancel, lets the agent end its turn before the child goes. */ export const ACP_STOP_GRACE_MS = 4_000 +/** How long a model or effort pick waits for the agent's answer, as Claude's and Codex's do. */ +export const ACP_OPTION_WRITE_TIMEOUT_MS = 30_000 export type AcpStructuredSessionAdapterDeps = { spec: AcpLaunchSpec @@ -39,5 +41,7 @@ export type AcpStructuredSessionAdapterDeps = { stopGraceMs?: number /** Bounds the handshake and session load or creation; past it the start fails. */ startupTimeoutMs?: number + /** Bounds a pick's wait for the agent's answer; past it the pick fails. */ + optionWriteTimeoutMs?: number isWindowsProcessStartTimeAvailable?: () => boolean } diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 1fe4ccd7d458..c9494fe48f24 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -30,9 +30,11 @@ import { import { AcpStructuredStarts, type AcpStartAttempt } from './acp-structured-starts' import { acpPromptBlocks } from './acp-structured-turns' import { + ACP_OPTION_WRITE_TIMEOUT_MS, ACP_STOP_GRACE_MS, type AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' +import { writeAcpSessionOption } from './acp-structured-options' export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter { /** Live children, and ones whose exit is not yet proven; a proven exit removes its entry. */ @@ -202,13 +204,12 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte if (!write) { throw new Error(`${session.spec.agent} offers no session option named ${input.key}`) } - if (write.method === 'config') { - const result = await session.runtime.setConfigOption(write.configId, write.value) - session.options.adoptConfigOptions(result.configOptions) - } else { - await session.runtime.setModel(write.modelId) - session.options.adoptModel(write.modelId) - } + // Bounded, and abandoned by a close or Stop: the session's queue waits on it. + await writeAcpSessionOption(session.runtime, session.options, write, { + agent: session.spec.agent, + timeoutMs: this.deps.optionWriteTimeoutMs ?? ACP_OPTION_WRITE_TIMEOUT_MS, + ...(input.signal ? { signal: input.signal } : {}) + }) return session.options.reported() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts index d51d0d78cd41..a3eabd09f0e5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts @@ -1,6 +1,7 @@ -// The acquire each session has in flight, owned by the host that runs it. The session's queue runs -// one attach at a time, so a session has at most one; a close, a Stop admitted now, or quit aborts -// it from outside the queue instead of waiting behind a start the provider may never answer. +// The acquire each session has in flight, owned by the host that runs it, or another provider wait +// its queue is on (an option write). The queue runs one step at a time, so a session has at most +// one; a close, a Stop admitted now, or quit aborts it from outside the queue instead of waiting +// behind a provider that may never answer. export class StructuredAgentSessionAcquireAborts { private readonly inFlight = new Map() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 4e961f58e337..d870dd7b9d36 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -242,6 +242,9 @@ export type StructuredAgentSessionSetOptionInput = { key: string value: string fence: number + /** Aborted by a close, a Stop admitted now, or quit, which must not wait behind a pick the + * provider never answers: the write fails. An adapter that bounds its own write may ignore it. */ + signal?: AbortSignal } export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 2ad22e6faecc..59f55ef8c3b9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -41,6 +41,7 @@ import { agentSessionMutationAdmitsNow } from './structured-agent-session-mutati import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import { mutateWithChatStop } from './structured-agent-session-chat-stop' +import { performSetOption } from './structured-agent-session-turns-options' export type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { @@ -185,10 +186,18 @@ export async function setStructuredAgentSessionOption( get conversationWrite() { return atRest() ? (true as const) : undefined }, - run: (ctx) => - atRest() - ? recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) - : plan.run(ctx) + run: async (ctx) => { + if (atRest()) { + return recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) + } + // Held where a start is, so a close, a Stop admitted now or quit ends the wait from outside. + const wait = context.beginProviderWait(params.envelope.sessionId) + try { + return await performSetOption(ctx, params, wait.signal) + } finally { + wait.end() + } + } }, openForProviderWrite(context, params.envelope) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 9e883a8e5883..4d3aa92846f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -293,6 +293,7 @@ export class StructuredAgentSessionHost { stopAgent: (sessionId, ending) => this.lifetime.stopAgent(sessionId, ending), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), abortAcquire: (sessionId, reason) => this.runtimeState.acquireAborts.abort(sessionId, reason), + beginProviderWait: (sessionId) => this.runtimeState.acquireAborts.begin(sessionId), now: () => this.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 41b2a8e523a8..9a135eab333e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -46,6 +46,8 @@ export type StructuredAgentSessionMutationContext = { wakeQueuedDrain?: (sessionId: string) => void /** Aborts the acquire the session has in flight, from outside its serialize. */ abortAcquire: (sessionId: string, reason: string) => void + /** Registers a provider wait other than a start, which `abortAcquire` then aborts too. */ + beginProviderWait: (sessionId: string) => { signal: AbortSignal; end: () => void } now: () => number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts index 9230e56cbe83..ffee03165f3f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts @@ -4,14 +4,16 @@ import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-se export async function performSetOption( ctx: AgentSessionTurnContext, - input: { key: string; value: string } + input: { key: string; value: string }, + signal?: AbortSignal ): Promise> { let applied: void | Readonly> try { applied = await ctx.adapter.setOption({ sessionId: ctx.sessionId, ...input, - fence: ctx.fence + fence: ctx.fence, + ...(signal ? { signal } : {}) }) } catch (error) { if (isAgentSessionOptionRejectedError(error)) { From 27a9db0e3e0e1b6e8811d28343ab67480a76102a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:08:03 -0700 Subject: [PATCH 189/269] fix(agent-launch): a phone's launch opens a terminal for an agent whose chat it cannot show agent.launch now reads the caller's capabilities by the rule tabs and restart offers use (clientRendersStructuredAgent). A phone without registered-agents.v1 gets Grok as a terminal again, as on main; the host's own callers and desktop clients are unchanged. --- .../agent-launch/agent-launch-executor.ts | 5 +- src/main/agent-launch/agent-launch-mode.ts | 7 ++ .../agent-launch-caller-renders.test.ts | 80 +++++++++++++++++++ .../rpc/methods/agent-launch-replay.test.ts | 5 +- .../rpc/methods/agent-launch.test-fixture.ts | 15 +++- src/main/runtime/rpc/methods/agent-launch.ts | 8 +- 6 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 37fa94bc0b8e..a26d1085b2dd 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -68,6 +68,8 @@ export type AgentLaunchExecution = { surfaces: AgentLaunchSurfaceFactory workspaces?: AgentLaunchWorkspaceFactory vocabulary?: AgentLaunchModeVocabulary + /** False when the calling client cannot show the agent's chat; absent for the host's own callers. */ + callerRendersStructured?: boolean /** Attributes a throw to the step that was running, the way a dispatch's own stages do. */ onStage?: (stage: 'worktree_create' | 'mode_settle' | 'surface_create') => void } @@ -89,7 +91,8 @@ export async function executeAgentLaunch( ...(intent.cwd ? { cwd: intent.cwd } : {}), ...(intent.target.kind === 'existing' && intent.target.workspacePath ? { workspacePath: intent.target.workspacePath } - : {}) + : {}), + ...(execution.callerRendersStructured === false ? { callerRendersStructured: false } : {}) }, settings, vocabulary diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 4f69e0728844..acc31f9908b0 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -82,6 +82,9 @@ export type AgentLaunchModePlacement = { /** The root of the workspace the launch lands in, when the host has resolved it. Without it a * requested `cwd` cannot be proven to name the root and is read as custom. */ workspacePath?: string + /** False when the client asking for the launch cannot show this agent's chat; absent for the + * host's own callers, which can. */ + callerRendersStructured?: boolean } const REGISTERED_STRUCTURED_AGENTS: readonly string[] = [...STRUCTURED_AGENT_STORAGE.keys()] @@ -170,6 +173,10 @@ export function decideAgentLaunchMode(args: { if (!support.supported) { return downgraded(BLOCKER_REASON[support.blocker], vocabulary) } + // A chat its caller can neither show nor close is no launch; that caller gets the terminal. + if (placement.callerRendersStructured === false) { + return downgraded(BLOCKER_REASON['client-capability'], vocabulary) + } return { mode: 'structured', preferred: 'structured', diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts b/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts new file mode 100644 index 000000000000..19264b410d7c --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts @@ -0,0 +1,80 @@ +/** + * `agent.launch` makes a chat only for a caller that can show it: a client that cannot read an + * agent's chat gets a terminal, by the rule tabs and restart offers use. The host's own callers + * (CLI, orchestration over the runtime socket) carry no capability list and are unaffected. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RpcContext } from '../core' +import { + CAPABLE_CLIENT, + methodNamed, + PHONE_LAUNCH_CAPABILITIES, + rpcContext, + runtimeStub +} from './agent-launch.test-fixture' + +const createStructuredSession = vi.fn(async (_args: Record) => ({ + ok: true as const, + value: { sessionId: 'sess-1' } +})) + +vi.mock('./structured-agent-session-create', () => ({ + createStructuredAgentSessionForWorktree: (args: Record) => + createStructuredSession(args) +})) + +const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') +const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') + +async function launchInto(agent: string, context: Partial) { + const runtime = runtimeStub() + const parsed = AGENT_LAUNCH.params.safeParse({ + agent, + target: { kind: 'existing', worktree: 'id:wt-7' } + }) + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? 'invalid') + } + const result = await AGENT_LAUNCH.handler(parsed.data, rpcContext(runtime, context)) + return { result, runtime } +} + +beforeEach(() => { + createStructuredSession.mockClear() +}) + +describe('a launch the caller cannot show as a chat', () => { + it('opens Grok as a terminal for a phone that reads only Claude and Codex chats', async () => { + const { result, runtime } = await launchInto('grok', CAPABLE_CLIENT) + expect(result.outcome).toMatchObject({ kind: 'terminal', handle: 'term_1' }) + expect(result.receipt).toMatchObject({ mode: 'terminal', preferred: 'structured' }) + expect(createStructuredSession).not.toHaveBeenCalled() + expect(runtime.createTerminal).toHaveBeenCalledTimes(1) + }) + + it('still opens Claude and Codex as chats for that phone', async () => { + for (const agent of ['claude', 'codex']) { + const { result } = await launchInto(agent, CAPABLE_CLIENT) + expect(result.outcome).toMatchObject({ kind: 'structured' }) + } + }) + + it('opens Grok as a chat for a client that reads registered agents', async () => { + const { result, runtime } = await launchInto('grok', { + ...CAPABLE_CLIENT, + clientCapabilities: [ + ...PHONE_LAUNCH_CAPABILITIES, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ] + }) + expect(result.outcome).toMatchObject({ kind: 'structured', sessionId: 'sess-1' }) + expect(runtime.createTerminal).not.toHaveBeenCalled() + }) + + it("opens Grok as a chat for the host's own callers, which carry no capability list", async () => { + const { result } = await launchInto('grok', {}) + expect(result.outcome).toMatchObject({ kind: 'structured', sessionId: 'sess-1' }) + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts index 9e725c7b5178..c04788791c83 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -38,6 +38,7 @@ import type { OrcaRuntimeService } from '../../orca-runtime' import { RpcDispatcher } from '../dispatcher' import { methodNamed, + PHONE_LAUNCH_CAPABILITIES, rpcContext, runtimeStub, type AgentLaunchRuntimeStub @@ -83,12 +84,12 @@ const PAIRED_CLIENT: Partial = { clientKind: 'mobile', pairedDeviceId: 'device-1', clientId: 'credential-a', - clientCapabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY] + clientCapabilities: PHONE_LAUNCH_CAPABILITIES } const ROTATED_CREDENTIAL_CLIENT: Partial = { ...PAIRED_CLIENT, clientId: 'credential-b', - clientCapabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY] + clientCapabilities: PHONE_LAUNCH_CAPABILITIES } let directory: string diff --git a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts index 365c843c1ef5..91a2382d1b81 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts @@ -6,7 +6,11 @@ */ import { vi } from 'vitest' -import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + AGENT_LAUNCH_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import { AgentLaunchPaneAlreadyLiveError } from '../../../../shared/agent-launch-pane-already-live' import type { RpcContext } from '../core' @@ -131,8 +135,15 @@ export function rpcContext( return { runtime, ...context } as unknown as RpcContext } +/** What a phone advertises that reads Claude and Codex chats; a launch reads it to pick the mode. */ +export const PHONE_LAUNCH_CAPABILITIES = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AGENT_LAUNCH_RUNTIME_CAPABILITY +] + export const CAPABLE_CLIENT: Partial = { clientKind: 'mobile', pairedDeviceId: 'device-1', - clientCapabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY] + clientCapabilities: PHONE_LAUNCH_CAPABILITIES } diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index 574dfd1a30f4..55fc7f80fc24 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -51,6 +51,7 @@ import { selectAgentLaunchTabForCaller } from './agent-launch-caller-selection' import { agentLaunchWorkspaceFactory } from './agent-launch-worktree-creation' +import { clientRendersStructuredAgent } from './structured-agent-session-policy' /** * Advertising `agent.launch.v2` is a client's statement that it understands EITHER outcome — a @@ -164,7 +165,12 @@ async function runAgentLaunch( callerNavigationId === null, terminalSpawn ), - workspaces: agentLaunchWorkspaceFactory(context, intent.agent) + workspaces: agentLaunchWorkspaceFactory(context, intent.agent), + // The rule tabs and restart offers use: a phone that cannot show this agent's chat gets a terminal. + ...(context.clientKind !== undefined && + !clientRendersStructuredAgent(context.clientCapabilities, intent.agent) + ? { callerRendersStructured: false } + : {}) }) if (callerNavigationId !== null) { selectAgentLaunchTabForCaller(context.runtime, result, callerNavigationId) From d2e32d1134a8a3976f8cd30d17536abf6ff2fe48 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:09:03 -0700 Subject: [PATCH 190/269] fix(acp): strip every agent hook variable from the ACP child, from the shared list ACP_CHILD_ENV_TO_DELETE was a second copy of the hook runtime keys that missed ORCA_AGENT_HOOK_TRANSPORT; it now spreads AGENT_HOOK_RUNTIME_ENV_KEYS beside the pane identity keys. --- src/main/acp/acp-launch-specs.ts | 15 ++++++++------- .../acp/acp-structured-session-adapter.test.ts | 3 ++- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 764ed89907d3..3e21bdb6e8a2 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -2,6 +2,11 @@ // speaks protocol extensions). Nothing outside `acp-dialects/` branches on an agent's name. import { join } from 'node:path' +import { + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ORCA_SCRUB_SAFE_PANE_ENV +} from '../../shared/agent-hook-scrub-safe-env' +import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' import type { AcpDialect } from './acp-dialects/acp-dialect' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' @@ -59,11 +64,7 @@ export const ACP_CHILD_ENV_TO_DELETE: readonly string[] = [ 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID', 'ORCA_AGENT_LAUNCH_TOKEN', - 'ORCA_AGENT_PANE', - 'ORCA_AGENT_LAUNCH', - 'ORCA_AGENT_HOOK_PORT', - 'ORCA_AGENT_HOOK_TOKEN', - 'ORCA_AGENT_HOOK_ENDPOINT', - 'ORCA_AGENT_HOOK_ENV', - 'ORCA_AGENT_HOOK_VERSION' + ORCA_SCRUB_SAFE_PANE_ENV, + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ...AGENT_HOOK_RUNTIME_ENV_KEYS ] diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 4446e0802014..9648808285d8 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -10,6 +10,7 @@ import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { ACP_CHILD_ENV_TO_DELETE } from './acp-launch-specs' +import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' import { GROK, openAcpAdapterRig, @@ -68,7 +69,7 @@ describe('ACP structured session adapter: acquire', () => { const launch = rig.child().launch expect(launch.envToDelete).toEqual(expect.arrayContaining([...ACP_CHILD_ENV_TO_DELETE])) expect(launch.envToDelete).toEqual( - expect.arrayContaining(['ORCA_PANE_KEY', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN']) + expect.arrayContaining(['ORCA_PANE_KEY', 'ORCA_AGENT_PANE', ...AGENT_HOOK_RUNTIME_ENV_KEYS]) ) expect(launch.env).toMatchObject({ ORCA_AGENT_SESSION_ID: SESSION, From 2776c80c4bc5232e93baf08cdc962daec0720a66 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:09:48 -0700 Subject: [PATCH 191/269] refactor(native-chat): the mutation context carries the provider-wait registry itself Keeps the host file within its line limit; one field instead of two closures over it. --- .../structured-agent-session-host-mutations.ts | 4 ++-- .../agent-session-wire/structured-agent-session-host.ts | 3 +-- .../structured-agent-session-mutation-context.ts | 8 ++++---- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 59f55ef8c3b9..73919c932e25 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -149,7 +149,7 @@ function abortAcquireForStop( ) { return } - context.abortAcquire(envelope.sessionId, 'stopped while starting') + context.acquireAborts.abort(envelope.sessionId, 'stopped while starting') } export function respondToStructuredAgentSessionPrompt( @@ -191,7 +191,7 @@ export async function setStructuredAgentSessionOption( return recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) } // Held where a start is, so a close, a Stop admitted now or quit ends the wait from outside. - const wait = context.beginProviderWait(params.envelope.sessionId) + const wait = context.acquireAborts.begin(params.envelope.sessionId) try { return await performSetOption(ctx, params, wait.signal) } finally { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 4d3aa92846f0..8761b47f8fc1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -292,8 +292,7 @@ export class StructuredAgentSessionHost { wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), stopAgent: (sessionId, ending) => this.lifetime.stopAgent(sessionId, ending), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), - abortAcquire: (sessionId, reason) => this.runtimeState.acquireAborts.abort(sessionId, reason), - beginProviderWait: (sessionId) => this.runtimeState.acquireAborts.begin(sessionId), + acquireAborts: this.runtimeState.acquireAborts, now: () => this.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 9a135eab333e..099b356cb5e3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -13,6 +13,7 @@ import { } from './structured-agent-session-mutation-admission' import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime' +import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, @@ -44,10 +45,9 @@ export type StructuredAgentSessionMutationContext = { * journal commit (a conversation command). Draft-table changes need no call: * the draft store notifies through the journal's own commit listener. */ wakeQueuedDrain?: (sessionId: string) => void - /** Aborts the acquire the session has in flight, from outside its serialize. */ - abortAcquire: (sessionId: string, reason: string) => void - /** Registers a provider wait other than a start, which `abortAcquire` then aborts too. */ - beginProviderWait: (sessionId: string) => { signal: AbortSignal; end: () => void } + /** The provider wait each session's serialize is on (a start, an option write), which a caller + * outside that serialize aborts. */ + acquireAborts: Pick now: () => number } From a24cd1115e72aa95270c8fde6bcb31a54ad4c9a7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:21:37 -0700 Subject: [PATCH 192/269] fix(agent-launch): agent.launch.v2 still vouches for Claude and Codex chats The caller rule from the previous commit also turned Claude and Codex into terminals for a client advertising only agent.launch.v2, whose contract says it opens a chat (mobile retry-authority tests). Only an agent beyond those two now needs the client to read it (clientRendersStructuredAgent); the test fixtures go back to what they were. --- .../agent-launch-caller-renders.test.ts | 27 +++++++++---------- .../rpc/methods/agent-launch-replay.test.ts | 5 ++-- .../rpc/methods/agent-launch.test-fixture.ts | 15 ++--------- src/main/runtime/rpc/methods/agent-launch.ts | 23 ++++++++++++---- 4 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts b/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts index 19264b410d7c..5925bbdc450c 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts @@ -1,19 +1,17 @@ /** - * `agent.launch` makes a chat only for a caller that can show it: a client that cannot read an - * agent's chat gets a terminal, by the rule tabs and restart offers use. The host's own callers - * (CLI, orchestration over the runtime socket) carry no capability list and are unaffected. + * `agent.launch` makes a chat only for a caller that can show it. `agent.launch.v2` vouches for + * Claude and Codex chats; any other agent's chat needs the client to read it, by the rule tabs and + * restart offers use, else it gets a terminal. The host's own callers (CLI, orchestration over the + * runtime socket) carry no capability list and are unaffected. */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import type { RpcContext } from '../core' import { - CAPABLE_CLIENT, - methodNamed, - PHONE_LAUNCH_CAPABILITIES, - rpcContext, - runtimeStub -} from './agent-launch.test-fixture' + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { RpcContext } from '../core' +import { CAPABLE_CLIENT, methodNamed, rpcContext, runtimeStub } from './agent-launch.test-fixture' const createStructuredSession = vi.fn(async (_args: Record) => ({ ok: true as const, @@ -46,7 +44,7 @@ beforeEach(() => { }) describe('a launch the caller cannot show as a chat', () => { - it('opens Grok as a terminal for a phone that reads only Claude and Codex chats', async () => { + it('opens Grok as a terminal for a client that reads only Claude and Codex chats', async () => { const { result, runtime } = await launchInto('grok', CAPABLE_CLIENT) expect(result.outcome).toMatchObject({ kind: 'terminal', handle: 'term_1' }) expect(result.receipt).toMatchObject({ mode: 'terminal', preferred: 'structured' }) @@ -54,7 +52,7 @@ describe('a launch the caller cannot show as a chat', () => { expect(runtime.createTerminal).toHaveBeenCalledTimes(1) }) - it('still opens Claude and Codex as chats for that phone', async () => { + it('still opens Claude and Codex as chats for that client', async () => { for (const agent of ['claude', 'codex']) { const { result } = await launchInto(agent, CAPABLE_CLIENT) expect(result.outcome).toMatchObject({ kind: 'structured' }) @@ -65,7 +63,8 @@ describe('a launch the caller cannot show as a chat', () => { const { result, runtime } = await launchInto('grok', { ...CAPABLE_CLIENT, clientCapabilities: [ - ...PHONE_LAUNCH_CAPABILITIES, + ...(CAPABLE_CLIENT.clientCapabilities ?? []), + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY ] }) diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts index c04788791c83..9e725c7b5178 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -38,7 +38,6 @@ import type { OrcaRuntimeService } from '../../orca-runtime' import { RpcDispatcher } from '../dispatcher' import { methodNamed, - PHONE_LAUNCH_CAPABILITIES, rpcContext, runtimeStub, type AgentLaunchRuntimeStub @@ -84,12 +83,12 @@ const PAIRED_CLIENT: Partial = { clientKind: 'mobile', pairedDeviceId: 'device-1', clientId: 'credential-a', - clientCapabilities: PHONE_LAUNCH_CAPABILITIES + clientCapabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY] } const ROTATED_CREDENTIAL_CLIENT: Partial = { ...PAIRED_CLIENT, clientId: 'credential-b', - clientCapabilities: PHONE_LAUNCH_CAPABILITIES + clientCapabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY] } let directory: string diff --git a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts index 91a2382d1b81..365c843c1ef5 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts @@ -6,11 +6,7 @@ */ import { vi } from 'vitest' -import { - AGENT_LAUNCH_RUNTIME_CAPABILITY, - CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from '../../../../shared/protocol-version' +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { AgentLaunchPaneAlreadyLiveError } from '../../../../shared/agent-launch-pane-already-live' import type { RpcContext } from '../core' @@ -135,15 +131,8 @@ export function rpcContext( return { runtime, ...context } as unknown as RpcContext } -/** What a phone advertises that reads Claude and Codex chats; a launch reads it to pick the mode. */ -export const PHONE_LAUNCH_CAPABILITIES = [ - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - AGENT_LAUNCH_RUNTIME_CAPABILITY -] - export const CAPABLE_CLIENT: Partial = { clientKind: 'mobile', pairedDeviceId: 'device-1', - clientCapabilities: PHONE_LAUNCH_CAPABILITIES + clientCapabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY] } diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index 55fc7f80fc24..383840d21c29 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -70,6 +70,23 @@ export function supportsAgentLaunch( ) } +/** + * `agent.launch.v2` was defined when Claude and Codex were the only chats, so it vouches for those + * two. Any other agent's chat needs the client to say it reads it, by the rule tabs and restart + * offers use; a client that does not gets that agent as a terminal. + */ +function callerRendersLaunchedChat( + context: Pick, + agent: string +): boolean { + return ( + context.clientKind === undefined || + agent === 'claude' || + agent === 'codex' || + clientRendersStructuredAgent(context.clientCapabilities, agent) + ) +} + /** * A client addresses a workspace by selector, but the result's `worktreeId` is an id and every * step below the executor re-prefixes it as `id:`. Resolving here is what keeps a @@ -166,11 +183,7 @@ async function runAgentLaunch( terminalSpawn ), workspaces: agentLaunchWorkspaceFactory(context, intent.agent), - // The rule tabs and restart offers use: a phone that cannot show this agent's chat gets a terminal. - ...(context.clientKind !== undefined && - !clientRendersStructuredAgent(context.clientCapabilities, intent.agent) - ? { callerRendersStructured: false } - : {}) + ...(callerRendersLaunchedChat(context, intent.agent) ? {} : { callerRendersStructured: false }) }) if (callerNavigationId !== null) { selectAgentLaunchTabForCaller(context.runtime, result, callerNavigationId) From 879f74a2c948e37632c3d99f3d4cd9d9bdaa31ab Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:01:11 -0700 Subject: [PATCH 193/269] refactor(native-chat): drop saved-history adoption from the timeline assembler The common pattern discards the history a provider replays while loading a session, so the assembler has no use for an input.history event. --- ...rovider-timeline-assembler-restart.test.ts | 126 +----------------- .../provider-timeline-assembler.ts | 1 - .../provider-timeline-decision.ts | 3 - .../provider-timeline-event.ts | 14 -- .../provider-timeline-turn-decisions.ts | 26 ---- 5 files changed, 1 insertion(+), 169 deletions(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts index a61ae858b26f..536381911be4 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts @@ -3,144 +3,20 @@ // decides from the journal the sweep left. import { afterEach, describe, expect, it } from 'vitest' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { settleStaleStructuredAgentSessionState } from '../agent-session-wire/structured-agent-session-dead-generation-settlement' import { closeProviderTimelineRigs, - messageText, openProviderTimelineRig, openUnboundProviderTimelineAssembler, pendingApproval, providerItemId, providerTurnItemId, runningTool, - SESSION, - type ProviderTimelineRig + SESSION } from './provider-timeline-assembler-test-support' -import type { ProviderTimelineEvent } from './provider-timeline-event' afterEach(closeProviderTimelineRigs) -const userText = (text: string): AgentJournalMessageItem => ({ - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text }] -}) - -/** A provider's saved history of one prompt, as an adapter adopting the session replays it. */ -const history: ProviderTimelineEvent[] = [ - { type: 'input.history', item: 'u1', body: userText('Fix it'), join: { turn: 'p1' } }, - { type: 'turn.open', turn: 'p1', at: 1_000 }, - { - type: 'text.delta', - item: { id: 'a1' }, - channel: 'assistant', - text: 'Looking', - join: { turn: 'p1' } - }, - { type: 'text.close', item: { id: 'a1' }, join: { turn: 'p1' } }, - { type: 'item.open', item: 'call-1', body: runningTool('read'), join: { turn: 'p1' } }, - { - type: 'item.close', - item: 'call-1', - body: { ...runningTool('read'), state: 'completed' }, - join: { turn: 'p1' } - }, - { type: 'turn.end', turn: 'p1', at: 2_000, state: 'completed', outcome: 'success' } -] - -async function revisions(rig: ProviderTimelineRig): Promise<[string, number][]> { - return (await rig.rows()).map((row) => [row.itemId, row.revision]) -} - -describe('an adopted session', () => { - it('lands the provider history as rows keyed by its own ids, opened by its saved message', async () => { - const rig = await openProviderTimelineRig() - history.forEach((event) => rig.assembler.apply(event)) - rig.assembler.flush() - - const user = await rig.row(providerItemId('item', 'u1')) - expect(user).toMatchObject({ - body: userText('Fix it'), - turnScope: { kind: 'turn', turnItemId: providerTurnItemId('p1') } - }) - expect(await rig.turn('p1')).toMatchObject({ - state: 'completed', - outcome: 'success', - userItemId: user?.itemId - }) - expect(messageText((await rig.row(providerItemId('item', 'a1')))?.body)).toBe('Looking') - expect((await rig.row(providerItemId('item', 'call-1')))?.body).toMatchObject({ - state: 'completed' - }) - - // Adoption run again by the next child (its first try was interrupted): nothing new lands. - const before = await revisions(rig) - const again = await rig.restart() - history.forEach((event) => again.apply(event)) - again.flush() - expect(await revisions(rig)).toEqual(before) - }) - - it('re-runs an adoption a crash cut midway without a second copy or a regressed row', async () => { - const rig = await openProviderTimelineRig() - history.slice(0, 5).forEach((event) => rig.assembler.apply(event)) - rig.assembler.flush() - await rig.rows() - // The child died with the turn and its tool running; the next acquisition sweeps them. - await rig.restart() - const swept = await rig.turn('p1') - expect(swept?.state).not.toBe('running') - const before = (await rig.rows()).length - - // The new child replays the whole history before its sink binds: only the writes can see it. - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal, { - generation: 'gen-3' - }) - history.forEach((event) => expect(assembler.apply(event).dropped).toBeUndefined()) - assembler.flush() - await bind() - - expect(await rig.rows()).toHaveLength(before) - expect(await rig.turn('p1')).toEqual(swept) - expect((await rig.row(providerItemId('item', 'call-1')))?.body).toMatchObject({ - state: 'failed' - }) - expect(messageText((await rig.row(providerItemId('item', 'a1')))?.body)).toBe('Looking') - }) - - // Temporary limit, for the adoption work to lift: the sweep settled the cut turn as the dead - // child's work, so the re-run cannot finish it. - it('leaves the turn a crash cut as the sweep settled it when the adoption re-runs', async () => { - const rig = await openProviderTimelineRig() - const reply: ProviderTimelineEvent[] = [ - { - type: 'text.delta', - item: { id: 'a2' }, - channel: 'assistant', - text: 'Done', - join: { turn: 'p1' } - }, - { type: 'text.close', item: { id: 'a2' }, join: { turn: 'p1' } } - ] - const full = [...history.slice(0, -1), ...reply, ...history.slice(-1)] - full.slice(0, 5).forEach((event) => rig.assembler.apply(event)) - await rig.restart() - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal, { - generation: 'gen-3' - }) - full.forEach((event) => assembler.apply(event)) - assembler.flush() - await bind() - - expect((await rig.turn('p1'))?.state).toBe('unverifiable') - expect((await rig.row(providerItemId('item', 'call-1')))?.body).toMatchObject({ - state: 'failed' - }) - expect(await rig.row(providerItemId('item', 'a2'))).toBeUndefined() - }) -}) - describe('a resumed session', () => { it('decides events admitted before bind against the journal the sweep left', async () => { const rig = await openProviderTimelineRig() diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts index f1b6cb0a68b0..fdd10ab61041 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -176,7 +176,6 @@ export function createProviderTimelineAssembler( return { admission: ADMITTED } } case 'input.accepted': - case 'input.history': case 'turn.open': case 'turn.end': case 'item.open': diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts index e5c985aaece1..61fdfe4cd496 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -26,7 +26,6 @@ import type { ProviderTimelineTurnRef } from './provider-timeline-rows' import type { ProviderTimelineState } from './provider-timeline-state' import { decideContextUsage, - decideHistory, decideInput, decideSessionEnd, decideTurnEnd, @@ -105,8 +104,6 @@ export function decideProviderTimelineEvent( switch (event.type) { case 'input.accepted': return decideInput(input, event) - case 'input.history': - return decideHistory(input, event) case 'turn.open': return decideTurnOpen(input, event) case 'turn.end': diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts index dd2f7d1497fb..4cd8b8650ffe 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -23,11 +23,6 @@ // or the next `turn.open`. Its running tool calls are still the provider's: a close reported // after the Stop lands as reported, and whatever is still running settles at the provider's // `turn.end` for it (or the next `turn.open`, or `session.ended`). -// - Saved history the provider replays goes only into an empty journal (an adopted session), as -// ordinary events with the provider's own ids, so re-running an interrupted adoption writes the -// same rows again. Limit, until the adoption work lifts it: a crash that cut the first run -// inside a turn leaves that turn as the restart's sweep settled it (`unverifiable`, its running -// tools `failed`), and the re-run writes nothing more into it. // - An event the sink refused changed nothing. Re-apply the same event to retry it (after // `backpressure`); `failed` and `closed` are final. // - A provider item is (thread, id): the same id on another thread is another item. Text and @@ -38,7 +33,6 @@ import type { AgentSessionContextUsage } from '../../../shared/agent-session-con import type { AgentJournalApprovalItem, AgentJournalItemBody, - AgentJournalMessageItem, AgentJournalProducerLinkage, AgentJournalQuestionItem, AgentJournalTurnOutcome @@ -84,14 +78,6 @@ export type ProviderTimelineEvent = requestedAt: number join?: ProviderTimelineJoin } - /** A user message from the provider's saved history (an adopted session has no Orca send): its - * own row, keyed by `item`, and the message that opened `join.turn`. */ - | { - type: 'input.history' - item: string - body: AgentJournalMessageItem - join: ProviderTimelineJoin & { turn: string } - } /** A turn began. `turn` is the provider's turn id when it has one; the assembler mints one otherwise. */ | { type: 'turn.open'; turn?: string; at: number } /** The provider ended a turn. Absent `turn` means the open turn, else the one a person stopped diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts index e9c4471f6076..fd1399c4afc8 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts @@ -10,13 +10,11 @@ import { agentJournalTurnBody, readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { agentJournalTurnRowReservedBytes, resolveAgentJournalTurnRowWrite } from './agent-journal-turn-row-revision' -import { providerTimelinePlacement } from './provider-timeline-context' import type { ProviderTimelineDecidedEvent, ProviderTimelineDecision, @@ -187,30 +185,6 @@ export function decideInput( ) } -/** A saved user message of an adopted session: its own row, only where none is, and the opener of - * the turn it names. */ -export function decideHistory( - input: ProviderTimelineDecisionInput, - event: Extract -): ProviderTimelineDecision { - const { context, state } = input - const row = context.rows.item('item', providerKey(event.item), event.join.thread ?? null) - const write: ProviderTimelineResolvedWrite = { identity: row.identity, body: event.body } - const opener = decideOpener(input, { userItemId: row.itemId }, event.join.turn) - return { - ...opener, - writes: [ - { - reservedBytes: estimateStructuredAgentSessionItemBytes(row.identity, event.body), - lifecycle: false, - options: { turnScope: providerTimelinePlacement(context, state, event.join) }, - resolve: (at) => (at.itemBody(row.itemId) === null ? write : null) - }, - ...(opener.writes ?? []) - ] - } -} - /** A user message names the turn it opened: the one it names once that one opens, else the open * turn while that still names no message of its own, else the next to open. */ function decideOpener( From 0f4be81a83c1640c800b91d9257fae2672009701 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:06:04 -0700 Subject: [PATCH 194/269] refactor(acp): drop session/load history adoption from the translator The common pattern discards the history an agent replays during session/load, keeping only what it says about the context window. Remove the adoption path (acp-history-adoption.ts, the adopt option, and the historical background-task liveness rewrite it fed) so load replay is always dropped except usage. --- src/main/acp/acp-background-task-timeline.ts | 9 +- src/main/acp/acp-context-usage.ts | 2 +- src/main/acp/acp-history-adoption.ts | 60 ------------ src/main/acp/acp-session-update.ts | 6 +- ...-timeline-background-task-evidence.test.ts | 46 +-------- ...p-timeline-background-task-results.test.ts | 55 +---------- src/main/acp/acp-timeline-dialect.test.ts | 2 +- .../acp/acp-timeline-fixture.test-support.ts | 17 +--- src/main/acp/acp-timeline-generic.test.ts | 52 +--------- src/main/acp/acp-timeline-recordings.test.ts | 61 +----------- src/main/acp/acp-timeline-translator.ts | 98 +++---------------- src/main/acp/acp-turn-messages.ts | 2 +- 12 files changed, 36 insertions(+), 374 deletions(-) delete mode 100644 src/main/acp/acp-history-adoption.ts diff --git a/src/main/acp/acp-background-task-timeline.ts b/src/main/acp/acp-background-task-timeline.ts index 414e9f6c104b..c3aa3f51f006 100644 --- a/src/main/acp/acp-background-task-timeline.ts +++ b/src/main/acp/acp-background-task-timeline.ts @@ -25,8 +25,7 @@ export class AcpBackgroundTaskTimeline { translate( updates: AcpBackgroundTaskUpdate[], - join: ProviderTimelineJoin, - history = false + join: ProviderTimelineJoin ): ProviderTimelineEvent[] { return updates.map((update) => { const { fallbackLabel, fallbackKind, ...fields } = update @@ -37,11 +36,7 @@ export class AcpBackgroundTaskTimeline { ...fields, kind: update.kind ?? previous?.kind ?? fallbackKind ?? 'unknown', label: update.label ?? previous?.label ?? fallbackLabel ?? update.taskId, - // A historical launch proves no current liveness; explicit outcomes still settle it. - state: - history && ['working', 'monitoring', 'waiting'].includes(update.state) - ? 'unverifiable' - : update.state + state: update.state } for (const key of ['label', 'summary', 'error', 'outputFile'] as const) { const text = block[key] diff --git a/src/main/acp/acp-context-usage.ts b/src/main/acp/acp-context-usage.ts index d36c1d2d7eac..007e56413abd 100644 --- a/src/main/acp/acp-context-usage.ts +++ b/src/main/acp/acp-context-usage.ts @@ -31,7 +31,7 @@ export class AcpContextTimeline { ] } - /** History that is not adopted is dropped except what it says about the context window. */ + /** Replayed history is dropped except what it says about the context window. */ history( update: SessionUpdate | undefined, usage: AgentSessionContextUsage | undefined, diff --git a/src/main/acp/acp-history-adoption.ts b/src/main/acp/acp-history-adoption.ts deleted file mode 100644 index 46c5181c5050..000000000000 --- a/src/main/acp/acp-history-adoption.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' -import { - boundInlineText, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' -import type { SessionUpdate } from './generated/acp-protocol.generated' - -type UserMessageChunk = Extract - -/** Saved history replayed into an empty journal (an adopted session). Ids come from the provider - * or from the replay's own order, so re-running an interrupted adoption lands the same rows. */ -export class AcpHistoryAdoption { - /** The history turn later frames that name none belong to. */ - turn?: string - private serial = 0 - private user?: { messageId: string | null; text: string } - - constructor(private readonly thread: string) {} - - turnFor(providerTurn: string | undefined): string { - return providerTurn ?? this.turn ?? `history:${this.serial++}` - } - - /** A chunk of the saved user message already buffered. */ - continuesUser(update: UserMessageChunk): boolean { - return this.user !== undefined && this.user.messageId === (update.messageId ?? null) - } - - /** Buffers a saved user message: `input.history` takes its whole body once. */ - observeUser(update: UserMessageChunk): void { - const messageId = update.messageId ?? null - const previous = this.continuesUser(update) ? (this.user?.text ?? '') : '' - const text = update.content.type === 'text' ? update.content.text : '' - this.user = { - messageId, - text: boundInlineText(previous + text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text - } - } - - /** The buffered user message, as the opener of `turn`. */ - takeUser(turn: string): ProviderTimelineEvent[] { - const user = this.user - this.user = undefined - if (!user?.text) { - return [] - } - return [ - { - type: 'input.history', - item: `history-user:${JSON.stringify([turn, user.messageId])}`, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: user.text }] }, - join: { thread: this.thread, turn } - } - ] - } - - get holdsUser(): boolean { - return this.user !== undefined - } -} diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index 74df1762fa10..1f39a4d0ed6c 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -14,8 +14,6 @@ export function acpSessionUpdate( turn: string | undefined, at: number, context: { - /** Adopted history: background-task starts in it prove no current liveness. */ - history: boolean tools: AcpToolTimeline dialect: AcpDialect backgroundTasks: AcpBackgroundTaskTimeline @@ -43,7 +41,7 @@ export function acpSessionUpdate( ] : [{ type: 'provider.frame', frameKind: update.sessionUpdate, payload: update, ...join }] case 'user_message_chunk': - // A live echo of the send is the send's own row; adopted history comes as `input.history`. + // A live echo of the send is the send's own row. return [] case 'tool_call': case 'tool_call_update': { @@ -54,7 +52,7 @@ export function acpSessionUpdate( tool && 'body' in tool && tool.body?.kind === 'tool-call' ? (dialect.toolBackgroundTasks?.(update, tool.body) ?? []) : [] - return [...events, ...backgroundTasks.translate(tasks, join.join, context.history)] + return [...events, ...backgroundTasks.translate(tasks, join.join)] } case 'plan': return [ diff --git a/src/main/acp/acp-timeline-background-task-evidence.test.ts b/src/main/acp/acp-timeline-background-task-evidence.test.ts index 5abadae51958..6e5725c8283d 100644 --- a/src/main/acp/acp-timeline-background-task-evidence.test.ts +++ b/src/main/acp/acp-timeline-background-task-evidence.test.ts @@ -196,9 +196,9 @@ describe('background task outcome evidence', () => { ) it.each(['launch', 'notification'] as const)( - 'SF3 treats replayed %s evidence as unverifiable and accepts a later completion', + 'SF3 drops replayed %s evidence during load and accepts a later completion', async (source) => { - const fixture = await openAcpFixtureRig({ adopt: true }) + const fixture = await openAcpFixtureRig() fixture.lane().beginLoad() if (source === 'launch') { tool( @@ -220,49 +220,9 @@ describe('background task outcome evidence', () => { taskNotice(fixture, taskId, true) } fixture.finishLoad() - expect((await taskRows(fixture))[0]?.block.state).toBe('unverifiable') + expect(await taskRows(fixture)).toEqual([]) taskNotice(fixture, taskId, false, true) expect((await taskRows(fixture))[0]?.block.state).toBe('done') } ) - - it.each(['notification', 'kill'] as const)( - 'SF3 preserves the explicit %s outcome included in adopted history', - async (completion) => { - const fixture = await openAcpFixtureRig({ adopt: true }) - fixture.lane().beginLoad() - tool( - fixture, - { - toolCallId: 'historic-tool', - status: 'completed', - rawOutput: { - type: 'BackgroundTaskStarted', - task_id: taskId, - task_type: 'bash', - command: 'sleep 99' - } - }, - true - ) - if (completion === 'notification') { - taskNotice(fixture, taskId, true, true) - } else { - tool( - fixture, - { - toolCallId: 'historic-kill', - name: 'kill_command_or_subagent', - status: 'completed', - rawInput: { task_id: taskId } - }, - true - ) - } - fixture.finishLoad() - expect((await taskRows(fixture))[0]?.block.state).toBe( - completion === 'notification' ? 'done' : 'idle' - ) - } - ) }) diff --git a/src/main/acp/acp-timeline-background-task-results.test.ts b/src/main/acp/acp-timeline-background-task-results.test.ts index 5a863b45cdfd..48baeaba557a 100644 --- a/src/main/acp/acp-timeline-background-task-results.test.ts +++ b/src/main/acp/acp-timeline-background-task-results.test.ts @@ -5,60 +5,7 @@ import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' afterEach(closeProviderTimelineRigs) -describe('background task results in adopted history', () => { - it.each([ - ['running', undefined, 'unverifiable'], - ['completed', 0, 'done'], - ['completed', undefined, 'done'], - ['failed', 1, 'blocked'], - ['stopped', undefined, 'idle'], - ['unknown', undefined, 'unverifiable'] - ] as const)( - 'settles replay from a TaskOutput result with status %s and exit code %s', - async (status, exitCode, state) => { - const fixture = await openAcpFixtureRig({ adopt: true }) - const lane = fixture.lane() - lane.beginLoad() - for (const [index, rawOutput] of [ - { type: 'BackgroundTaskStarted', task_id: 'historic-task', command: 'npm test' }, - { - type: 'TaskOutput', - Result: { - task_id: 'historic-task', - command: 'npm test', - status, - exit_code: exitCode, - output: 'PASS a.test.js\nPASS b.test.js\n' - } - } - ].entries()) { - fixture.apply( - lane.notification( - 'session/update', - { - sessionId: 'session-1', - _meta: { isReplay: true, promptId: 'historic-turn' }, - update: { - sessionUpdate: 'tool_call_update', - toolCallId: `tool-${index}`, - status: 'completed', - rawOutput - } - }, - 1000 + index - ) - ) - } - fixture.finishLoad() - const tasks = (await fixture.rig.rows()).flatMap((row) => - row.body.kind === 'message' ? row.body.blocks.filter(isBackgroundTaskBlock) : [] - ) - expect(tasks).toHaveLength(1) - expect(tasks[0]).toMatchObject({ state, label: 'npm test' }) - expect(tasks[0]?.summary ?? '').not.toContain('PASS') - } - ) - +describe('background task results', () => { it('uses each structured kill result rather than inferring a stop for unsuccessful targets', async () => { const fixture = await openAcpFixtureRig() const lane = fixture.lane() diff --git a/src/main/acp/acp-timeline-dialect.test.ts b/src/main/acp/acp-timeline-dialect.test.ts index f58ce2618d8c..195ab2d0a91a 100644 --- a/src/main/acp/acp-timeline-dialect.test.ts +++ b/src/main/acp/acp-timeline-dialect.test.ts @@ -206,7 +206,7 @@ describe('ACP dialect request and turn boundaries', () => { stop_reason: 'end_turn' } }) - apply(translator.finishLoad(1300)) + translator.finishLoad() expect( (await rig.rows()).flatMap((row) => row.body.kind === 'message' ? [messageText(row.body)] : [] diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts index 24e94f926a3a..ae9eac456e47 100644 --- a/src/main/acp/acp-timeline-fixture.test-support.ts +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -65,18 +65,12 @@ function fixtureAnswer( } } -/** Captured frames go through translation, assembly and the real serialized journal. `adopt` is - * the creator's decision that `session/load` history goes into an empty journal. */ -export async function openAcpFixtureRig(options: { adopt?: boolean } = {}) { +/** Captured frames go through translation, assembly and the real serialized journal. */ +export async function openAcpFixtureRig() { const rig = await openProviderTimelineRig() let providerSessionId = 'session-1' - let adopt = options.adopt ?? false const translator = () => - new AcpTimelineTranslator({ - sessionId: providerSessionId, - dialect: GROK_ACP_DIALECT, - adopt - }) + new AcpTimelineTranslator({ sessionId: providerSessionId, dialect: GROK_ACP_DIALECT }) let lane = translator() const requests = new Map< string | number, @@ -165,11 +159,10 @@ export async function openAcpFixtureRig(options: { adopt?: boolean } = {}) { feed, lane: () => lane, /** A new provider child: the sweep, a new generation's assembler and a new translator. */ - restart: async (next: { adopt?: boolean } = {}) => { + restart: async () => { await rig.restart() - adopt = next.adopt ?? false lane = translator() }, - finishLoad: () => apply(lane.finishLoad(2000)) + finishLoad: () => lane.finishLoad() } } diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts index 9d08552577fe..b7f6fc8a666a 100644 --- a/src/main/acp/acp-timeline-generic.test.ts +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -12,7 +12,7 @@ import { AcpTimelineTranslator, acpTurnEnd } from './acp-timeline-translator' afterEach(closeProviderTimelineRigs) -async function genericRig(options: { adopt?: boolean; agentName?: string } = {}) { +async function genericRig(options: { agentName?: string } = {}) { const rig = await openProviderTimelineRig() const translator = new AcpTimelineTranslator({ sessionId: 'provider-1', ...options }) const apply = (events: ProviderTimelineEvent[]) => { @@ -243,20 +243,7 @@ describe('generic ACP translation', () => { }) }) - it('adopts generic unmarked load history and never invents a success verdict', async () => { - const { rig, translator, apply, update } = await genericRig({ adopt: true }) - translator.beginLoad() - update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'User' } }) - update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'History' } }) - apply(translator.finishLoad(1200)) - expect((await rig.turns())[0]).toMatchObject({ state: 'completed' }) - expect((await rig.turns())[0]).not.toHaveProperty('outcome') - expect( - (await rig.rows()).flatMap((row) => (row.body.kind === 'message' ? [row.body.role] : [])) - ).toEqual(['user', 'assistant']) - }) - - it('drops unmarked load history its creator does not adopt, except context usage', () => { + it('drops unmarked load history, except context usage', () => { const translator = new AcpTimelineTranslator({ sessionId: 'provider-1' }) translator.beginLoad() const events = [ @@ -270,10 +257,9 @@ describe('generic ACP translation', () => { expect(events).toEqual([ expect.objectContaining({ type: 'context.usage', join: { thread: 'provider-1' } }) ]) - expect(translator.finishLoad(1200)).toEqual([]) }) - it('requires an explicit journal decision for marked replay and uses typed standard events', async () => { + it('drops marked replay outside a load and uses typed standard events', async () => { const { rig, translator, apply } = await genericRig() expect( translator.notification( @@ -308,38 +294,6 @@ describe('generic ACP translation', () => { apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) expect((await rig.rows()).some((row) => messageText(row.body) === 'typed')).toBe(true) }) - it('adopts streamed user history across multiple turns without replacing earlier users', async () => { - const { rig, translator, apply, update } = await genericRig({ adopt: true }) - translator.beginLoad() - update({ - sessionUpdate: 'user_message_chunk', - messageId: 'user-1', - content: { type: 'text', text: 'First ' } - }) - update({ - sessionUpdate: 'user_message_chunk', - messageId: 'user-1', - content: { type: 'text', text: 'question' } - }) - update({ - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: 'First answer' } - }) - update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'Second ' } }) - update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'question' } }) - update({ - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: 'Second answer' } - }) - apply(translator.finishLoad(1200)) - expect( - (await rig.rows()).flatMap((row) => - row.body.kind === 'message' ? [messageText(row.body)] : [] - ) - ).toEqual(['First question', 'First answer', 'Second question', 'Second answer']) - expect(await rig.turns()).toHaveLength(2) - }) - it('evicts completed snapshots during long turns while preserving active tool input', async () => { const { rig, translator, apply, update } = await genericRig() apply(translator.openPrompt('send-1', 1000).events) diff --git a/src/main/acp/acp-timeline-recordings.test.ts b/src/main/acp/acp-timeline-recordings.test.ts index 5bcb22d73cb4..e4402b37e864 100644 --- a/src/main/acp/acp-timeline-recordings.test.ts +++ b/src/main/acp/acp-timeline-recordings.test.ts @@ -2,8 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { contextTokensFromUsage } from '../../shared/agent-session-context-usage' import { closeProviderTimelineRigs, - messageText, - providerItemId + messageText } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' @@ -73,12 +72,12 @@ describe('recorded ACP traffic through the journal', () => { expect(rows.some((row) => messageText(row.body)?.includes('ok'))).toBe(true) }) - it('drops load history a journal already holds, except its context usage', async () => { + it('drops load history, except its context usage', async () => { const fixture = await openAcpFixtureRig() const frames = await readAcpFixture('s4-resume') await fixture.feed(frames.filter((frame) => frame.process === 'A')) const before = await fixture.rig.rows() - await fixture.restart({ adopt: false }) + await fixture.restart() const loading = frames.filter((frame) => frame.process === 'B') const nextPrompt = loading.findIndex((frame) => frame.message.method === 'session/prompt') await fixture.feed(loading.slice(0, nextPrompt)) @@ -107,60 +106,6 @@ describe('recorded ACP traffic through the journal', () => { expect(await fixture.rig.turns()).toHaveLength(2) }) - it('adopts coalesced load replay into an empty journal, including user and completed tools', async () => { - const fixture = await openAcpFixtureRig({ adopt: true }) - const frames = (await readAcpFixture('s4-resume')).filter((frame) => frame.process === 'B') - const nextPrompt = frames.findIndex((frame) => frame.message.method === 'session/prompt') - await fixture.feed(frames.slice(0, nextPrompt)) - fixture.finishLoad() - const rows = await fixture.rig.rows() - expect(rows.flatMap((row) => (row.body.kind === 'tool-call' ? [row.body] : []))).toHaveLength(2) - expect( - rows.filter((row) => row.body.kind === 'message' && row.body.role === 'user') - ).toHaveLength(1) - expect(rows.some((row) => messageText(row.body)?.includes('Read notes.txt'))).toBe(true) - expect((await fixture.rig.turns())[0]).toMatchObject({ state: 'completed', outcome: 'success' }) - expect(rows.every((row) => row.body.kind === 'turn' || row.turnScope?.kind === 'turn')).toBe( - true - ) - await fixture.feed(frames.slice(nextPrompt)) - expect(await fixture.rig.turns()).toHaveLength(2) - }) - - it('adopts history as rows with the provider ids, and a re-run of the adoption adds no copy', async () => { - const fixture = await openAcpFixtureRig({ adopt: true }) - const frames = (await readAcpFixture('s4-resume')).filter((frame) => frame.process === 'B') - const load = frames.slice( - 0, - frames.findIndex((frame) => frame.message.method === 'session/prompt') - ) - await fixture.feed(load) - fixture.finishLoad() - const adopted = await fixture.rig.rows() - const user = adopted.find((row) => row.body.kind === 'message' && row.body.role === 'user') - expect(user?.itemId).toBe( - providerItemId('item', `history-user:${JSON.stringify(['prompt:A:3', null])}`, { - thread: 'session-1' - }) - ) - expect(await fixture.rig.turn('prompt:A:3')).toMatchObject({ - userItemId: user?.itemId, - state: 'completed' - }) - expect( - adopted.flatMap((row) => (row.body.kind === 'tool-call' ? [row.itemId] : [])).sort() - ).toEqual( - ['tool:call-1', 'tool:call-2'] - .map((key) => providerItemId('item', key, { thread: 'session-1' })) - .sort() - ) - - await fixture.restart({ adopt: true }) - await fixture.feed(load) - fixture.finishLoad() - expect(await fixture.rig.rows()).toEqual(adopted) - }) - it('answers questions and plan approval with the recorded exact reply shapes', async () => { const fixture = await openAcpFixtureRig() const rows = await fixture.feed(await readAcpFixture('s5-plan-approved')) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 3794b0410460..9f927bceed0f 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -9,7 +9,6 @@ import { type AcpRequestPresentation } from './acp-dialects/acp-dialect' import type { AcpAgentError } from './acp-errors' -import { AcpHistoryAdoption } from './acp-history-adoption' import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' import { translateAcpRequest } from './acp-timeline-requests' @@ -17,7 +16,7 @@ import { acpSessionUpdate } from './acp-session-update' import { AcpToolTimeline } from './acp-tool-timeline' import { AcpTurnFailures, acpPromptErrorDetail } from './acp-turn-failures' import { AcpTurnMessages } from './acp-turn-messages' -import type { PromptResponse, SessionNotification } from './generated/acp-protocol.generated' +import type { PromptResponse } from './generated/acp-protocol.generated' export { acpTurnEnd } from './acp-prompt-turns' @@ -34,10 +33,6 @@ const SUBSTANTIVE_UPDATES = [ export type AcpTimelineTranslatorOptions = { sessionId: string dialect?: AcpDialect - /** The creator's decision, made from the journal before spawn: translate `session/load` history - * because the journal is empty (an adopted session). Otherwise history stays out of the - * timeline and only its context usage reads on. */ - adopt?: boolean /** The agent's display name, for a failed turn the provider gave no words for. */ agentName?: string } @@ -52,7 +47,7 @@ export class AcpTimelineTranslator { private readonly messages = new AcpTurnMessages() private readonly started = new BoundedMap({ maxEntries: 128 }) private readonly failures: AcpTurnFailures - private load?: { adoption?: AcpHistoryAdoption } + private loading = false private readonly context = new AcpContextTimeline() private activeTurn?: string @@ -71,7 +66,7 @@ export class AcpTimelineTranslator { clientMessageId: string, at: number ): { promptId: string; events: ProviderTimelineEvent[] } { - if (this.load) { + if (this.loading) { throw new Error('ACP prompt overlaps a prompt or load') } return this.prompts.open(clientMessageId, at) @@ -118,25 +113,16 @@ export class AcpTimelineTranslator { return this.context.models(models, at, this.dialect, { thread: this.options.sessionId }) } + /** The history `session/load` replays is dropped, except what it says about the context window. */ beginLoad(): void { - if (this.prompts.current || this.load) { + if (this.prompts.current || this.loading) { throw new Error('ACP load overlaps a prompt or load') } - this.load = this.options.adopt - ? { adoption: new AcpHistoryAdoption(this.options.sessionId) } - : {} + this.loading = true } - finishLoad(at: number): ProviderTimelineEvent[] { - const adoption = this.load?.adoption - this.load = undefined - if (!adoption) { - return [] - } - const events = adoption.holdsUser - ? this.openHistory(adoption, adoption.turnFor(undefined), at) - : [] - return [...events, ...this.endHistory(adoption, at)] + finishLoad(): void { + this.loading = false } sessionEvent(event: AcpSessionEvent, at: number): ProviderTimelineEvent[] { @@ -165,36 +151,23 @@ export class AcpTimelineTranslator { const update = standard?.update const markedReplay = (envelope.success && envelope.data._meta?.isReplay === true) || extension?.replay === true - const history = markedReplay || (extension?.replay === undefined && this.load !== undefined) - const adoption = history ? this.load?.adoption : undefined - if (history && !adoption) { + if (markedReplay || (extension?.replay === undefined && this.loading)) { return this.context.history(update, extension?.usage, at, { thread: this.options.sessionId }) } - if (adoption && update?.sessionUpdate === 'user_message_chunk') { - return this.historyUser(adoption, update, at) - } const providerTurn = extension?.turn const opens = (update !== undefined && SUBSTANTIVE_UPDATES.includes(update.sessionUpdate)) || extension?.end !== undefined || extension?.started === true - const turn = adoption - ? opens - ? adoption.turnFor(providerTurn) - : (providerTurn ?? adoption.turn) - : (providerTurn ?? - (this.prompts.current?.opened ? this.prompts.current.turn : this.activeTurn)) + const turn = + providerTurn ?? (this.prompts.current?.opened ? this.prompts.current.turn : this.activeTurn) const events: ProviderTimelineEvent[] = [] if (turn && opens) { - events.push( - ...(adoption - ? this.openHistory(adoption, turn, extension?.at ?? at) - : this.start(turn, extension?.at ?? at)) - ) + events.push(...this.start(turn, extension?.at ?? at)) } const join = { thread: this.options.sessionId, ...(turn === undefined ? {} : { turn }) } if (extension?.backgroundTasks) { - events.push(...this.backgroundTasks.translate(extension.backgroundTasks, join, history)) + events.push(...this.backgroundTasks.translate(extension.backgroundTasks, join)) } if (extension?.usage) { events.push(...this.context.update(extension.usage, join)) @@ -215,15 +188,13 @@ export class AcpTimelineTranslator { return events } if (standard) { - // History text is keyed by its position so a re-run of the adoption lands on the same rows. const messageKey = - turn && (this.dialect.injectedPromptIdentity || history) + turn && this.dialect.injectedPromptIdentity ? this.messages.key(turn, standard.update) : undefined return [ ...events, ...acpSessionUpdate(standard, turn, at, { - history, tools: this.tools, dialect: this.dialect, backgroundTasks: this.backgroundTasks, @@ -266,44 +237,6 @@ export class AcpTimelineTranslator { return events } - /** A saved user message opens a new history turn, unless the provider names its turn. */ - private historyUser( - adoption: AcpHistoryAdoption, - update: Extract, - at: number - ): ProviderTimelineEvent[] { - if (adoption.continuesUser(update)) { - adoption.observeUser(update) - return [] - } - const events = adoption.holdsUser - ? this.openHistory(adoption, adoption.turnFor(undefined), at) - : [] - events.push(...this.endHistory(adoption, at)) - adoption.observeUser(update) - return events - } - - private openHistory( - adoption: AcpHistoryAdoption, - turn: string, - at: number - ): ProviderTimelineEvent[] { - adoption.turn = turn - return [...this.start(turn, at), ...adoption.takeUser(turn)] - } - - /** History carries no verdict for a turn it does not end itself. */ - private endHistory(adoption: AcpHistoryAdoption, at: number): ProviderTimelineEvent[] { - const turn = adoption.turn - if (!turn) { - return [] - } - this.end(turn) - adoption.turn = undefined - return [{ type: 'turn.end', turn, at, state: 'completed' }] - } - private start(turn: string, at: number): ProviderTimelineEvent[] { if (turn === this.prompts.current?.turn) { const events = this.prompts.start(turn, at) @@ -326,8 +259,5 @@ export class AcpTimelineTranslator { if (this.activeTurn === turn) { this.activeTurn = undefined } - if (this.load?.adoption?.turn === turn) { - this.load.adoption.turn = undefined - } } } diff --git a/src/main/acp/acp-turn-messages.ts b/src/main/acp/acp-turn-messages.ts index e7b8a7e49bc9..0cf39c5834c3 100644 --- a/src/main/acp/acp-turn-messages.ts +++ b/src/main/acp/acp-turn-messages.ts @@ -3,7 +3,7 @@ import type { SessionUpdate } from './generated/acp-protocol.generated' type MessagePosition = { ordinal: number; channel?: string } -/** Stable turn-relative message positions also identify coalesced load snapshots. */ +/** Stable turn-relative message positions. */ export class AcpTurnMessages { private readonly turns = new BoundedMap({ maxEntries: 128 }) From 58c3dd548167f287371e4cef3d686419f32247e3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:10:00 -0700 Subject: [PATCH 195/269] refactor(native-chat): a pending input is only Orca's send now Review follow-up to the adoption removal: drop the comment naming the provider's saved message, and make requestedAt required since every pending input comes from input.accepted. --- .../agent-session-timeline/provider-timeline-state.ts | 4 ++-- .../provider-timeline-turn-decisions.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts index aa8b0a1bc82c..991dea1ce264 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -28,10 +28,10 @@ export type ProviderTimelineOpenTurn = ProviderTimelineTurnRef & { running: AgentJournalTurnLifecycle } -/** A user message waiting for the turn it opens: Orca's send, or the provider's saved one. */ +/** Orca's send, waiting for the turn it opens. */ export type ProviderTimelinePendingInput = { userItemId: string - requestedAt?: number + requestedAt: number /** The turn row the provider said it opens; absent: the next turn to open. */ turnItemId?: string } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts index fd1399c4afc8..7b1a5e4756e1 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts @@ -68,7 +68,7 @@ export function decideTurnOpen( state: 'running', userItemId: pending?.userItemId ?? turn.itemId, startedAt: event.at, - ...(pending?.requestedAt === undefined ? {} : { requestedAt: pending.requestedAt }) + ...(pending ? { requestedAt: pending.requestedAt } : {}) } const write: ProviderTimelineResolvedWrite = { identity: turn.identity, From 280246008141f60575793aa3a8511bc7b6124ff4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:12:18 -0700 Subject: [PATCH 196/269] test(acp): keep the task-result status table on live frames Review follow-up to the adoption removal: the result-status mapping was only tested through adopted history, so run the same table on live frames, and cover an unmarked task notice during a load being dropped. --- ...-timeline-background-task-evidence.test.ts | 3 +- ...p-timeline-background-task-results.test.ts | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/main/acp/acp-timeline-background-task-evidence.test.ts b/src/main/acp/acp-timeline-background-task-evidence.test.ts index 6e5725c8283d..d2e8bb4d5870 100644 --- a/src/main/acp/acp-timeline-background-task-evidence.test.ts +++ b/src/main/acp/acp-timeline-background-task-evidence.test.ts @@ -217,7 +217,8 @@ describe('background task outcome evidence', () => { true ) } else { - taskNotice(fixture, taskId, true) + // Unmarked: a task notice during a load is history too. + taskNotice(fixture, taskId) } fixture.finishLoad() expect(await taskRows(fixture)).toEqual([]) diff --git a/src/main/acp/acp-timeline-background-task-results.test.ts b/src/main/acp/acp-timeline-background-task-results.test.ts index 48baeaba557a..32becd1ffc5a 100644 --- a/src/main/acp/acp-timeline-background-task-results.test.ts +++ b/src/main/acp/acp-timeline-background-task-results.test.ts @@ -6,6 +6,57 @@ import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' afterEach(closeProviderTimelineRigs) describe('background task results', () => { + it.each([ + ['running', undefined, 'working'], + ['completed', 0, 'done'], + ['completed', undefined, 'done'], + ['failed', 1, 'blocked'], + ['stopped', undefined, 'idle'], + ['unknown', undefined, 'working'] + ] as const)( + 'settles a task from a TaskOutput result with status %s and exit code %s', + async (status, exitCode, state) => { + const fixture = await openAcpFixtureRig() + const lane = fixture.lane() + for (const [index, rawOutput] of [ + { type: 'BackgroundTaskStarted', task_id: 'task-1', command: 'npm test' }, + { + type: 'TaskOutput', + Result: { + task_id: 'task-1', + command: 'npm test', + status, + exit_code: exitCode, + output: 'PASS a.test.js\nPASS b.test.js\n' + } + } + ].entries()) { + fixture.apply( + lane.notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: 'task-turn' }, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: `tool-${index}`, + status: 'completed', + rawOutput + } + }, + 1000 + index + ) + ) + } + const tasks = (await fixture.rig.rows()).flatMap((row) => + row.body.kind === 'message' ? row.body.blocks.filter(isBackgroundTaskBlock) : [] + ) + expect(tasks).toHaveLength(1) + expect(tasks[0]).toMatchObject({ state, label: 'npm test' }) + expect(tasks[0]?.summary ?? '').not.toContain('PASS') + } + ) + it('uses each structured kill result rather than inferring a stop for unsuccessful targets', async () => { const fixture = await openAcpFixtureRig() const lane = fixture.lane() From d613623af27388b334b639a366c2e8bf868d93ba Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:22:44 -0700 Subject: [PATCH 197/269] test(acp): a frame helper for a shell command Grok is running --- .../acp/acp-structured-adapter.test-support.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index 2f38ad6c92fe..f39b1bf22a2c 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -236,6 +236,21 @@ export function replyChunk(promptId: string, text: string, meta: Record(assertion: () => T | Promise): Promise { return vi.waitFor(assertion, { timeout: 2_000, interval: 5 }) } From 03ca1d9b42bc47ac451aa3964c5ddab7422ca1fb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:59:41 -0700 Subject: [PATCH 198/269] fix(acp): a Grok crash settles through the host's provider-exit batch, scoped to the turn it ended A Grok crash ended the journal unverifiable before the adapter reported the exit, so the host's provider-exit settlement found no running turn and wrote nothing: the adapter's failure (with Grok's last words) never reached the journal, and a later stale-session pass wrote a bare, thread-scoped cut-short row, so the partial reply was not folded as Claude's and Codex's are. At a proven exit the ACP lane now ends its running turn interrupted at the exit instant, as the host's exit contract expects of a child's own translator (Codex's does the same). When Grok's stdout closed first (every POSIX crash), the turn is unverifiable only until the exit is proven: the host's provider-exit settlement now takes the exit as proof naming the child's fence and revises what that child left unverifiable in the same batch, with the turn-scoped row and the adapter's failure. Claude and Codex write no unverifiable turn of a live child except a command whose hand-off is in doubt; that turn is now revised at the exit instead of at the next open. --- .../acp/acp-structured-host-crash.test.ts | 121 ++++++++++++++++++ src/main/acp/acp-structured-session.ts | 27 +++- .../structured-agent-session-child-exit.ts | 15 ++- ...gent-session-dead-generation-settlement.ts | 46 ++++++- 4 files changed, 196 insertions(+), 13 deletions(-) create mode 100644 src/main/acp/acp-structured-host-crash.test.ts diff --git a/src/main/acp/acp-structured-host-crash.test.ts b/src/main/acp/acp-structured-host-crash.test.ts new file mode 100644 index 000000000000..ff7ade42a8fe --- /dev/null +++ b/src/main/acp/acp-structured-host-crash.test.ts @@ -0,0 +1,121 @@ +// A Grok crash mid-reply, through the real host: the host's settlement of the proven exit writes one +// `provider-exit:` batch whose cut-short row reports on the turn the crash ended, carrying the +// adapter's failure with Grok's last words. Whether Grok's stdout closed before its exit was seen +// changes only what the chat shows in between. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { PROVIDER_EXIT_ROW_PREFIX } from '../../shared/agent-session-stop-row-identity' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { JournalLifecycleBatchInput } from '../native-chat/agent-session-journal/journal-store-contracts' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { replyChunk, shellCall, waitFor } from './acp-structured-adapter.test-support' +import { openAttachedHostRig, promptIdOf, send } from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const LAST_WORDS = 'qa-cell20-last-words' + +/** Grok mid-reply ("Oak") with a command running, every lifecycle batch the journal takes recorded. */ +async function midReply() { + const rig = await openAttachedHostRig() + const { host } = rig + await send(host, 'hello') + const prompt = await rig.rig.frame('session/prompt') + const child = rig.rig.child() + child.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'Oak')) + child.agent.notify('session/update', shellCall(promptIdOf(prompt), 'in_progress')) + await rig.rig.settle() + await host.flushStreamedEvents(SESSION) + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + const batches: JournalLifecycleBatchInput[] = [] + const append = journal.appendLifecycleBatch.bind(journal) + vi.spyOn(journal, 'appendLifecycleBatch').mockImplementation((batch) => { + batches.push(batch) + return append(batch) + }) + child.stderr = LAST_WORDS + return { ...rig, child, batches } +} + +async function settledCrash(rig: Awaited>) { + const providerExits = () => + rig.batches.filter((batch) => + batch.settlementId.startsWith(`dead-generation:${PROVIDER_EXIT_ROW_PREFIX}`) + ) + await waitFor(() => expect(providerExits()).toHaveLength(1)) + const rows = await rig.rows() + const turn = rows.find((row) => readAgentJournalTurn(row.body) !== null) + const statuses = rows.filter((row) => row.body.kind === 'status') + return { batch: providerExits()[0]!, rows, turn, statuses } +} + +function expectOneTurnScopedCutShortRow(settled: Awaited>) { + const { turn, statuses, rows } = settled + expect(readAgentJournalTurn(turn?.body)).toMatchObject({ state: 'interrupted' }) + expect(statuses).toHaveLength(1) + const [row] = statuses + expect(decodeURIComponent(row?.itemId ?? '')).toContain(PROVIDER_EXIT_ROW_PREFIX) + expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId: turn?.itemId }) + expect( + readAgentSessionFailureFact(row?.body.kind === 'status' ? row.body.failure : undefined) + ).toEqual({ kind: 'providerExited', detail: { text: LAST_WORDS, audience: 'person' } }) + expect(rows.find((item) => item.body.kind === 'tool-call')?.body).toMatchObject({ + state: 'failed', + endedAs: 'interrupted' + }) +} + +describe('a Grok crash with its exit seen first', () => { + it('ends the turn at the exit, never unverifiable, and the provider-exit row reports on it', async () => { + const rig = await midReply() + rig.child.exit() + const settled = await settledCrash(rig) + expectOneTurnScopedCutShortRow(settled) + const states = rig.batches.flatMap((batch) => + batch.mutations.flatMap((mutation) => + mutation.kind === 'item' ? (readAgentJournalTurn(mutation.body)?.state ?? []) : [] + ) + ) + expect(states).not.toContain('unverifiable') + await rig.host.close(SESSION, 'user-close') + }) +}) + +describe('a Grok crash whose stdout closes before its exit is seen (every POSIX crash)', () => { + it('is unverifiable only until the exit is proven; the provider-exit batch then revises the turn', async () => { + const rig = await midReply() + // The child outlives its stream until Orca's close of it is proven. + let exitNow: () => void = () => undefined + rig.child.close = () => + new Promise((resolve) => { + exitNow = () => { + rig.child.exit() + resolve(true) + } + }) + rig.child.agent.close() + await waitFor(async () => + expect((await rig.turns()).map((turn) => turn.state)).toEqual(['unverifiable']) + ) + expect((await rig.rows()).filter((row) => row.body.kind === 'status')).toEqual([]) + + exitNow() + const settled = await settledCrash(rig) + expectOneTurnScopedCutShortRow(settled) + // The turn's revision, its call's and the row land together. + const turnKey = settled.turn!.itemId + expect( + settled.batch.mutations.flatMap((mutation) => + mutation.kind === 'item' ? [agentJournalItemKey(mutation.identity)] : [] + ) + ).toEqual(expect.arrayContaining([settled.statuses[0]!.itemId, turnKey])) + // No later stale-session pass rewrites it: nothing is left for one. + expect(rig.batches.filter((batch) => batch.settlementId.includes('stale-session'))).toEqual([]) + await rig.host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts index 64bb0e5d7cae..498f99fb2bda 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -3,7 +3,10 @@ import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { UNVERIFIABLE_TURN_VERDICT } from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' +import { + UNVERIFIABLE_TURN_VERDICT, + type StructuredAgentSessionTurnVerdict +} from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' import type { AcpLaunchSpec } from './acp-launch-specs' import type { AcpSessionEvent, AcpSessionRuntime } from './acp-session-runtime' import type { AcpStructuredChild } from './acp-structured-child' @@ -77,17 +80,21 @@ export function asReattachHistory(params: unknown): unknown { /** * Nothing more this child says reaches the journal, once: on its exit, or when its connection broke * while it may still run. Open requests die, held sends never left Orca, the running send's fate is - * unknown, and the running turn is one Orca never heard end (`unverifiable` until death evidence - * revises it). + * unknown, and the running turn ends as `verdict` says: `unverifiable` while the child may still + * run (the host's settlement of its proven exit revises it), interrupted at a proven exit. */ -export function closeAcpSessionJournal(session: AcpStructuredSession, reason: string): void { +export function closeAcpSessionJournal( + session: AcpStructuredSession, + reason: string, + verdict: StructuredAgentSessionTurnVerdict = UNVERIFIABLE_TURN_VERDICT +): void { if (session.journalClosed !== null) { return } session.journalClosed = reason session.prompts.clear() session.turns.end(reason) - session.lane.apply([{ type: 'session.ended', verdict: UNVERIFIABLE_TURN_VERDICT }]) + session.lane.apply([{ type: 'session.ended', verdict }]) session.lane.flush() session.lane.dispose() session.unbindReadingControl?.() @@ -114,7 +121,15 @@ export function endAcpStructuredSession( : session.journalClosed !== null && !stderr ? session.journalClosed : `${session.spec.agent} ACP agent exited${stderr ? `: ${stderr}` : ''}` - closeAcpSessionJournal(session, reason) + // A death of its own ended the running turn at the exit, as the host's exit settlement reads it + // (`exitedRootTurnScope`). A close Orca asked for leaves it `unverifiable`; that settlement revises it. + closeAcpSessionJournal( + session, + reason, + session.closeRequested + ? UNVERIFIABLE_TURN_VERDICT + : { state: 'interrupted', completedAt: observedAt } + ) const detail = stderr ? providerDiagnostic(stderr, 'person') : undefined onEvent?.({ type: 'ended', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index c606a73e310f..00191a566617 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -4,6 +4,7 @@ // record, and the record ends in `finally`. `expected` changes only what the chat is told. import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' +import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { PROVIDER_EXIT_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -142,6 +143,14 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< // Folded before the fallback's end is built, so the end reads it (`turnEndAfterStop`). await close?.recorded const generation = child.generation ?? 'unknown' + // The exit proves this child gone, as the record's death evidence later says: what it left + // `unverifiable` is revised now, not at the next open. + const exitProof: AgentSessionDeathEvidence = { + kind: 'exit-observed', + detail: exit.reason, + observedAt, + ownerFence: child.fence + } const settled = await settleStructuredAgentSessionDeadGeneration({ journal: session.journal, sessionId, @@ -161,12 +170,14 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< unfinishedStructuredAgentSessionWorkWasInterrupted( unfinishedWork, session.journal, - observedAt + observedAt, + exitProof )), ...(!expected && exit.failure ? { exitFailure: exit.failure } : {}), ...(!expected && exitedDuringStartup && child.generation ? { exitedDuringStartup: { generation: child.generation } } - : {}) + : {}), + exitProof }) if (!settled.ok) { logExitFailure(context, sessionId, 'exit-settlement', settled.error) 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 bd6c9dcfbf76..1054999d2eec 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 @@ -3,11 +3,15 @@ import { MAX_PROVIDER_DIAGNOSTIC_CHARS, type SubmissionRejectionFact } from '../../../shared/agent-session-failure' -import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + agentJournalItemKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' import { STALE_SESSION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, type AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' @@ -59,6 +63,8 @@ export type DeadGenerationJournal = { snapshot: () => Pick, 'items'> pendingSubmissions?: AgentSessionJournal['pendingSubmissions'] submissions?: () => DeadGenerationSubmission[] + itemFence: AgentSessionJournal['itemFence'] + stopMarks: AgentSessionJournal['stopMarks'] } export type StructuredAgentSessionUnfinishedWork = { @@ -83,12 +89,17 @@ function hasUnfinishedStructuredAgentSessionWork(journal: DeadGenerationJournal) export function unfinishedStructuredAgentSessionWorkWasInterrupted( before: StructuredAgentSessionUnfinishedWork, journal: DeadGenerationJournal, - observedExitAt: number + observedExitAt: number, + exitProof?: AgentSessionDeathEvidence ): boolean { const currentSnapshot = journal.snapshot() if (hasUnsettledSubmission(journal) || currentSnapshot.items.some(isInProgressItem)) { return true } + // A turn the exited child left `unverifiable` (its stream closed first) was running when it went. + if (provenUnverifiableTurnRevisions(currentSnapshot.items, exitProof, journal).length > 0) { + return true + } if ( currentSnapshot.items.some((item) => { const turn = readAgentJournalTurn(item.body) @@ -129,9 +140,18 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { /** The provider never finished starting: the start that failed, keyed by the child's * generation. Its row is the one the delivery loop writes for the same start. */ exitedDuringStartup?: { generation: string | null } + /** The exit as proof naming the exited child's fence: what that child's own translator could only + * end `unverifiable` (its stream closed before the exit was proven) is revised in this batch. */ + exitProof?: AgentSessionDeathEvidence }): Promise { try { - const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal) + const hasUnfinishedWork = + hasUnfinishedStructuredAgentSessionWork(input.journal) || + provenUnverifiableTurnRevisions( + input.journal.snapshot().items, + input.exitProof, + input.journal + ).length > 0 const showUnexpectedExitOutcome = input.showUnexpectedExitOutcome ?? hasUnfinishedWork if (!showUnexpectedExitOutcome && !hasUnfinishedWork) { return { ok: true } @@ -147,6 +167,10 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items + const proven = [ + ...provenUnverifiedToolCallRevisions(items, input.exitProof, input.journal), + ...provenUnverifiableTurnRevisions(items, input.exitProof, input.journal) + ] const mutations: JournalLifecycleMutationInput[] = [] if (showUnexpectedExitOutcome && input.exitedDuringStartup && startupFailure) { const startKey = input.exitedDuringStartup.generation ?? input.settlementId @@ -175,7 +199,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { ), tone: 'error' }, - turnScope: exitedRootTurnScope(items, input.verdict) + turnScope: exitedRootTurnScope(withRevisions(items, proven), input.verdict) }) } const bodies = new Map(items.map((item) => [item.itemId, item.body])) @@ -193,7 +217,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { }) } } - mutations.push(...runningTurnLifecycleRevisions(items, input.verdict)) + mutations.push(...runningTurnLifecycleRevisions(items, input.verdict), ...proven) const batchId = `dead-generation:${input.settlementId}` for (const chunk of partitionJournalLifecycleMutations(batchId, mutations)) { await input.journal.appendLifecycleBatch({ @@ -300,6 +324,18 @@ export async function settleStaleStructuredAgentSessionState(input: { return mutations.length } +function withRevisions( + items: readonly AgentJournalRenderItem[], + revisions: readonly JournalLifecycleMutationInput[] +): AgentJournalRenderItem[] { + const bodies = new Map( + revisions.flatMap((revision): [string, AgentJournalItemBody][] => + revision.kind === 'item' ? [[agentJournalItemKey(revision.identity), revision.body]] : [] + ) + ) + return items.map((item) => ({ ...item, body: bodies.get(item.itemId) ?? item.body })) +} + function isUnfinishedItem(item: AgentJournalRenderItem): boolean { return requiresTerminalSettlement(item.body) } From 8854fe8e495df1e55860fb5f79f904d5fb817f23 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:02:23 -0700 Subject: [PATCH 199/269] test(acp): a crash seen first leaves the host no Grok turn to revise --- src/main/acp/acp-structured-host-crash.test.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/acp/acp-structured-host-crash.test.ts b/src/main/acp/acp-structured-host-crash.test.ts index ff7ade42a8fe..c2e6ec854040 100644 --- a/src/main/acp/acp-structured-host-crash.test.ts +++ b/src/main/acp/acp-structured-host-crash.test.ts @@ -71,17 +71,16 @@ function expectOneTurnScopedCutShortRow(settled: Awaited { - it('ends the turn at the exit, never unverifiable, and the provider-exit row reports on it', async () => { + it('ends the turn at the exit itself; the provider-exit batch only reports on it', async () => { const rig = await midReply() rig.child.exit() const settled = await settledCrash(rig) expectOneTurnScopedCutShortRow(settled) - const states = rig.batches.flatMap((batch) => - batch.mutations.flatMap((mutation) => - mutation.kind === 'item' ? (readAgentJournalTurn(mutation.body)?.state ?? []) : [] - ) - ) - expect(states).not.toContain('unverifiable') + // The child's own end landed first, interrupted at the exit and never `unverifiable`: the host + // has no turn or call of it left to revise. + expect( + settled.batch.mutations.map((mutation) => mutation.kind === 'item' && mutation.body.kind) + ).toEqual(['status']) await rig.host.close(SESSION, 'user-close') }) }) From 459b8cba9036d5d271f223b62ac23967b9c0de12 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:09:29 -0700 Subject: [PATCH 200/269] refactor(native-chat): what a gone generation left unfinished gets its own module The settlement file passed 300 lines with the exit-proof revision. The unfinished-work reads (capture, interrupted-by-the-exit, in-progress) are their own concept and move out unchanged, apart from the exit proof they now take. --- .../structured-agent-session-child-exit.ts | 4 +- ...session-dead-generation-settlement.test.ts | 4 +- ...gent-session-dead-generation-settlement.ts | 133 ++---------------- ...tructured-agent-session-unfinished-work.ts | 129 +++++++++++++++++ 4 files changed, 146 insertions(+), 124 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index 00191a566617..5ef1715edac8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -19,12 +19,12 @@ import { type StructuredAgentSessionLeaseStore } from './structured-agent-session-lease-release' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' +import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' import { captureUnfinishedStructuredAgentSessionWork, - settleStructuredAgentSessionDeadGeneration, type DeadGenerationJournal, unfinishedStructuredAgentSessionWorkWasInterrupted -} from './structured-agent-session-dead-generation-settlement' +} from './structured-agent-session-unfinished-work' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import { evictStructuredAgentSession } from './structured-agent-session-eviction' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 1df56dc5228a..f5d1b9e2bc35 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -11,11 +11,11 @@ import { providerDiagnostic } from '../../../shared/agent-session-failure' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' import { captureUnfinishedStructuredAgentSessionWork, - settleStructuredAgentSessionDeadGeneration, unfinishedStructuredAgentSessionWorkWasInterrupted -} from './structured-agent-session-dead-generation-settlement' +} from './structured-agent-session-unfinished-work' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' 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 1054999d2eec..b6e448879f05 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 @@ -14,11 +14,9 @@ import { type AgentJournalItemBody, type AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import { - requiresTerminalSettlement, runningCallEnd, terminalAgentJournalBody } from '../agent-session-journal/journal-terminal-settlement' @@ -46,80 +44,16 @@ import { exitedRootTurnScope, runningRootTurnScope } from './structured-agent-session-exit-turn-scope' +import { + hasUnfinishedStructuredAgentSessionWork, + isInProgressStructuredAgentSessionItem, + type DeadGenerationJournal +} from './structured-agent-session-unfinished-work' /** Bounds the exit reason the lease keeps as log evidence; a provider diagnostic is held to the * same cap. */ export const MAX_UNEXPECTED_EXIT_REASON_CHARS = MAX_PROVIDER_DIAGNOSTIC_CHARS -type DeadGenerationSubmission = Pick< - ReturnType[number], - 'clientMessageId' | 'dispatchState' | 'recovered' | 'handoverRecorded' | 'handedOverAt' -> - -export type DeadGenerationJournal = { - appendLifecycleBatch: AgentSessionJournal['appendLifecycleBatch'] - markPendingSubmissionsUnknown: AgentSessionJournal['markPendingSubmissionsUnknown'] - rejectPendingSubmissions: AgentSessionJournal['rejectPendingSubmissions'] - snapshot: () => Pick, 'items'> - pendingSubmissions?: AgentSessionJournal['pendingSubmissions'] - submissions?: () => DeadGenerationSubmission[] - itemFence: AgentSessionJournal['itemFence'] - stopMarks: AgentSessionJournal['stopMarks'] -} - -export type StructuredAgentSessionUnfinishedWork = { - items: AgentJournalRenderItem[] - hadUnsettledSubmissions: boolean -} - -export function captureUnfinishedStructuredAgentSessionWork( - journal: DeadGenerationJournal -): StructuredAgentSessionUnfinishedWork { - return { - items: journal.snapshot().items.filter(isUnfinishedItem), - hadUnsettledSubmissions: hasUnsettledSubmission(journal) - } -} - -function hasUnfinishedStructuredAgentSessionWork(journal: DeadGenerationJournal): boolean { - const work = captureUnfinishedStructuredAgentSessionWork(journal) - return work.hadUnsettledSubmissions || work.items.length > 0 -} - -export function unfinishedStructuredAgentSessionWorkWasInterrupted( - before: StructuredAgentSessionUnfinishedWork, - journal: DeadGenerationJournal, - observedExitAt: number, - exitProof?: AgentSessionDeathEvidence -): boolean { - const currentSnapshot = journal.snapshot() - if (hasUnsettledSubmission(journal) || currentSnapshot.items.some(isInProgressItem)) { - return true - } - // A turn the exited child left `unverifiable` (its stream closed first) was running when it went. - if (provenUnverifiableTurnRevisions(currentSnapshot.items, exitProof, journal).length > 0) { - return true - } - if ( - currentSnapshot.items.some((item) => { - const turn = readAgentJournalTurn(item.body) - return turn?.state === 'interrupted' && turn.completedAt === observedExitAt - }) - ) { - return true - } - const inProgressBefore = before.items.filter(isInProgressItem) - if (inProgressBefore.length === 0) { - return false - } - const currentItems = new Map(currentSnapshot.items.map((item) => [item.itemId, item])) - const runningTurns = inProgressBefore.filter( - (item) => readAgentJournalTurn(item.body)?.state === 'running' - ) - const outcomeItems = runningTurns.length > 0 ? runningTurns : inProgressBefore - return outcomeItems.some((item) => !isCleanlySettled(currentItems.get(item.itemId))) -} - /** Whether the settlement was written, and what stopped it when it was not. */ export type StructuredAgentSessionDeadGenerationSettlement = | { ok: true } @@ -145,13 +79,10 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { exitProof?: AgentSessionDeathEvidence }): Promise { try { - const hasUnfinishedWork = - hasUnfinishedStructuredAgentSessionWork(input.journal) || - provenUnverifiableTurnRevisions( - input.journal.snapshot().items, - input.exitProof, - input.journal - ).length > 0 + const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork( + input.journal, + input.exitProof + ) const showUnexpectedExitOutcome = input.showUnexpectedExitOutcome ?? hasUnfinishedWork if (!showUnexpectedExitOutcome && !hasUnfinishedWork) { return { ok: true } @@ -290,7 +221,10 @@ export async function settleStaleStructuredAgentSessionState(input: { if ( evidence && (proven.length > 0 || - items.some((item) => isInProgressItem(item) && verdictFor(item).state === 'interrupted')) && + items.some( + (item) => + isInProgressStructuredAgentSessionItem(item) && verdictFor(item).state === 'interrupted' + )) && !endedByPersonsStop(journal, turnEnds) ) { mutations.unshift({ @@ -335,44 +269,3 @@ function withRevisions( ) return items.map((item) => ({ ...item, body: bodies.get(item.itemId) ?? item.body })) } - -function isUnfinishedItem(item: AgentJournalRenderItem): boolean { - return requiresTerminalSettlement(item.body) -} - -/** Work that means the provider was MID-RESPONSE. A pending approval or question is the provider - * waiting on the user, so dying while one sits there interrupted nothing — it still needs - * cancelling, but it must not claim a response was in progress. */ -function isInProgressItem(item: AgentJournalRenderItem): boolean { - return ( - readAgentJournalTurn(item.body)?.state === 'running' || - (item.body.kind === 'tool-call' && item.body.state === 'running') - ) -} - -function isCleanlySettled(item: AgentJournalRenderItem | undefined): boolean { - const turn = readAgentJournalTurn(item?.body) - if (turn) { - return turn.state === 'completed' - } - if (item?.body.kind === 'tool-call') { - return item.body.state === 'completed' - } - if (item?.body.kind === 'approval' || item?.body.kind === 'question') { - return item.body.resolution.state === 'resolved' - } - return false -} - -function hasUnsettledSubmission(journal: DeadGenerationJournal): boolean { - const submissions = journal.submissions?.() - return submissions - ? submissions.some( - (submission) => - // A queued message is not work in progress: nothing has it yet. - (submission.dispatchState === 'pending' && - !(submission.handoverRecorded && submission.handedOverAt === undefined)) || - (submission.dispatchState === 'unknown' && submission.recovered !== true) - ) - : (journal.pendingSubmissions?.().length ?? 0) > 0 -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts new file mode 100644 index 000000000000..4db133599160 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts @@ -0,0 +1,129 @@ +// What a gone child generation left unfinished in the journal, and whether its exit interrupted it. + +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { provenUnverifiableTurnRevisions } from './structured-agent-session-stale-turn-verdict' + +type DeadGenerationSubmission = Pick< + ReturnType[number], + 'clientMessageId' | 'dispatchState' | 'recovered' | 'handoverRecorded' | 'handedOverAt' +> + +export type DeadGenerationJournal = { + appendLifecycleBatch: AgentSessionJournal['appendLifecycleBatch'] + markPendingSubmissionsUnknown: AgentSessionJournal['markPendingSubmissionsUnknown'] + rejectPendingSubmissions: AgentSessionJournal['rejectPendingSubmissions'] + snapshot: () => Pick, 'items'> + pendingSubmissions?: AgentSessionJournal['pendingSubmissions'] + submissions?: () => DeadGenerationSubmission[] + itemFence: AgentSessionJournal['itemFence'] + stopMarks: AgentSessionJournal['stopMarks'] +} + +export type StructuredAgentSessionUnfinishedWork = { + items: AgentJournalRenderItem[] + hadUnsettledSubmissions: boolean +} + +export function captureUnfinishedStructuredAgentSessionWork( + journal: DeadGenerationJournal +): StructuredAgentSessionUnfinishedWork { + return { + items: journal.snapshot().items.filter(isUnfinishedItem), + hadUnsettledSubmissions: hasUnsettledSubmission(journal) + } +} + +/** `exitProof`: see `unfinishedStructuredAgentSessionWorkWasInterrupted`. */ +export function hasUnfinishedStructuredAgentSessionWork( + journal: DeadGenerationJournal, + exitProof?: AgentSessionDeathEvidence +): boolean { + const work = captureUnfinishedStructuredAgentSessionWork(journal) + return ( + work.hadUnsettledSubmissions || + work.items.length > 0 || + provenUnverifiableTurnRevisions(journal.snapshot().items, exitProof, journal).length > 0 + ) +} + +export function unfinishedStructuredAgentSessionWorkWasInterrupted( + before: StructuredAgentSessionUnfinishedWork, + journal: DeadGenerationJournal, + observedExitAt: number, + exitProof?: AgentSessionDeathEvidence +): boolean { + const currentSnapshot = journal.snapshot() + if ( + hasUnsettledSubmission(journal) || + currentSnapshot.items.some(isInProgressStructuredAgentSessionItem) + ) { + return true + } + // A turn the exited child left `unverifiable` (its stream closed first) was running when it went. + if (provenUnverifiableTurnRevisions(currentSnapshot.items, exitProof, journal).length > 0) { + return true + } + if ( + currentSnapshot.items.some((item) => { + const turn = readAgentJournalTurn(item.body) + return turn?.state === 'interrupted' && turn.completedAt === observedExitAt + }) + ) { + return true + } + const inProgressBefore = before.items.filter(isInProgressStructuredAgentSessionItem) + if (inProgressBefore.length === 0) { + return false + } + const currentItems = new Map(currentSnapshot.items.map((item) => [item.itemId, item])) + const runningTurns = inProgressBefore.filter( + (item) => readAgentJournalTurn(item.body)?.state === 'running' + ) + const outcomeItems = runningTurns.length > 0 ? runningTurns : inProgressBefore + return outcomeItems.some((item) => !isCleanlySettled(currentItems.get(item.itemId))) +} + +function isUnfinishedItem(item: AgentJournalRenderItem): boolean { + return requiresTerminalSettlement(item.body) +} + +/** Work that means the provider was MID-RESPONSE. A pending approval or question is the provider + * waiting on the user, so dying while one sits there interrupted nothing — it still needs + * cancelling, but it must not claim a response was in progress. */ +export function isInProgressStructuredAgentSessionItem(item: AgentJournalRenderItem): boolean { + return ( + readAgentJournalTurn(item.body)?.state === 'running' || + (item.body.kind === 'tool-call' && item.body.state === 'running') + ) +} + +function isCleanlySettled(item: AgentJournalRenderItem | undefined): boolean { + const turn = readAgentJournalTurn(item?.body) + if (turn) { + return turn.state === 'completed' + } + if (item?.body.kind === 'tool-call') { + return item.body.state === 'completed' + } + if (item?.body.kind === 'approval' || item?.body.kind === 'question') { + return item.body.resolution.state === 'resolved' + } + return false +} + +function hasUnsettledSubmission(journal: DeadGenerationJournal): boolean { + const submissions = journal.submissions?.() + return submissions + ? submissions.some( + (submission) => + // A queued message is not work in progress: nothing has it yet. + (submission.dispatchState === 'pending' && + !(submission.handoverRecorded && submission.handedOverAt === undefined)) || + (submission.dispatchState === 'unknown' && submission.recovered !== true) + ) + : (journal.pendingSubmissions?.().length ?? 0) > 0 +} From ed636659e45bcecf33e36b7c37ffd20fdf6f33fa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:14:52 -0700 Subject: [PATCH 201/269] refactor(native-chat): a watched exit revises what its child left unverifiable without reading Stop marks An exit's own instant is the turn's end, so the revision needs only each row's fence: the settlement's journal type gains itemFence alone, and the host test fakes say so. --- .../structured-agent-session-child-exit.ts | 12 +++------ ...session-dead-generation-settlement.test.ts | 2 ++ ...gent-session-dead-generation-settlement.ts | 20 ++++++-------- ...ctured-agent-session-stale-turn-verdict.ts | 26 +++++++++++++++++++ ...agent-session-startup-failure-exit.test.ts | 1 + ...ured-agent-session-unexpected-exit.test.ts | 5 ++++ ...tructured-agent-session-unfinished-work.ts | 17 ++++++------ 7 files changed, 54 insertions(+), 29 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index 5ef1715edac8..c30c66dbfd13 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -4,7 +4,6 @@ // record, and the record ends in `finally`. `expected` changes only what the chat is told. import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' -import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { PROVIDER_EXIT_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -145,12 +144,7 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< const generation = child.generation ?? 'unknown' // The exit proves this child gone, as the record's death evidence later says: what it left // `unverifiable` is revised now, not at the next open. - const exitProof: AgentSessionDeathEvidence = { - kind: 'exit-observed', - detail: exit.reason, - observedAt, - ownerFence: child.fence - } + const watched = { ownerFence: child.fence, observedAt } const settled = await settleStructuredAgentSessionDeadGeneration({ journal: session.journal, sessionId, @@ -171,13 +165,13 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< unfinishedWork, session.journal, observedAt, - exitProof + watched )), ...(!expected && exit.failure ? { exitFailure: exit.failure } : {}), ...(!expected && exitedDuringStartup && child.generation ? { exitedDuringStartup: { generation: child.generation } } : {}), - exitProof + exit: watched }) if (!settled.ok) { logExitFailure(context, sessionId, 'exit-settlement', settled.error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index f5d1b9e2bc35..18d76e5cd093 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -248,7 +248,9 @@ describe('dead structured-session generation settlement', () => { | 'rejectPendingSubmissions' | 'rejectQueuedSubmissions' | 'appendLifecycleBatch' + | 'itemFence' > = { + itemFence: () => undefined, snapshot: () => ({ ...settledSnapshot, items: [settledItem] 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 b6e448879f05..492997f12de9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -38,7 +38,9 @@ import { runningTurnLifecycleRevisions, stopFoundTurnLiveAt, turnVerdictFromDeathEvidence, - type StructuredAgentSessionTurnVerdict + watchedExitRevisions, + type StructuredAgentSessionTurnVerdict, + type StructuredAgentSessionWatchedExit } from './structured-agent-session-stale-turn-verdict' import { exitedRootTurnScope, @@ -74,15 +76,12 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { /** The provider never finished starting: the start that failed, keyed by the child's * generation. Its row is the one the delivery loop writes for the same start. */ exitedDuringStartup?: { generation: string | null } - /** The exit as proof naming the exited child's fence: what that child's own translator could only - * end `unverifiable` (its stream closed before the exit was proven) is revised in this batch. */ - exitProof?: AgentSessionDeathEvidence + /** The exit, watched: what that child's own translator could only end `unverifiable` (its stream + * closed before the exit was proven) is revised in this batch. */ + exit?: StructuredAgentSessionWatchedExit }): Promise { try { - const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork( - input.journal, - input.exitProof - ) + const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal, input.exit) const showUnexpectedExitOutcome = input.showUnexpectedExitOutcome ?? hasUnfinishedWork if (!showUnexpectedExitOutcome && !hasUnfinishedWork) { return { ok: true } @@ -98,10 +97,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items - const proven = [ - ...provenUnverifiedToolCallRevisions(items, input.exitProof, input.journal), - ...provenUnverifiableTurnRevisions(items, input.exitProof, input.journal) - ] + const proven = watchedExitRevisions(items, input.exit, input.journal) const mutations: JournalLifecycleMutationInput[] = [] if (showUnexpectedExitOutcome && input.exitedDuringStartup && startupFailure) { const startKey = input.exitedDuringStartup.generation ?? input.settlementId diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index ccd58b4af4ed..2491eb2f2be2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -124,6 +124,32 @@ export function provenUnverifiableTurnRevisions( }) } +/** An exit this host watched, of the child that holds `ownerFence`. */ +export type StructuredAgentSessionWatchedExit = { ownerFence: number; observedAt: number } + +/** What a watched exit revises of what its child left `unverifiable` (its stream closed before the + * exit was proven), calls and turns alike, as the record's death evidence later would. The exit's + * instant is the end, so no Stop mark is weighed. */ +export function watchedExitRevisions( + items: readonly AgentJournalRenderItem[], + exit: StructuredAgentSessionWatchedExit | undefined, + journal: Pick +): JournalLifecycleMutationInput[] { + if (!exit) { + return [] + } + const proof: AgentSessionDeathEvidence = { kind: 'exit-observed', detail: '', ...exit } + return [ + ...provenUnverifiedToolCallRevisions(items, proof, journal), + ...items.flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn?.state === 'unverifiable' && journal.itemFence(item.itemId) === exit.ownerFence + ? turnLifecycleRevision(item, turn, { state: 'interrupted', completedAt: exit.observedAt }) + : [] + }) + ] +} + /** The calls those settles closed with no proof, revised by the same proof: each only when it names * the owner that wrote the call, so a call that failed on its own stays failed. */ export function provenUnverifiedToolCallRevisions( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index 07f54bc2ab4a..0f11d0c6949a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -25,6 +25,7 @@ function startedSession(): StructuredAgentSessionChildExitSession & { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, // Nothing ran: the start failed before any response or acknowledged prompt. snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index e5222eae1b6a..1902c2fd2efe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -95,6 +95,7 @@ describe('provider-exit settlement', () => { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, snapshot: () => ({ items: [lifecycleItem('turn-1', 1, { state: 'running', startedAt: 1_000 })] }), @@ -171,6 +172,7 @@ describe('provider-exit settlement', () => { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []) @@ -290,6 +292,7 @@ describe('provider-exit settlement', () => { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []), @@ -349,6 +352,7 @@ describe('provider-exit settlement', () => { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), markPendingSubmissionsUnknown, @@ -402,6 +406,7 @@ describe('provider-exit settlement', () => { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, markPendingSubmissionsUnknown: vi.fn(async () => []), rejectPendingSubmissions: vi.fn(async () => []), snapshot: () => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts index 4db133599160..86d95aa9fbc7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts @@ -1,11 +1,13 @@ // What a gone child generation left unfinished in the journal, and whether its exit interrupted it. import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { provenUnverifiableTurnRevisions } from './structured-agent-session-stale-turn-verdict' +import { + watchedExitRevisions, + type StructuredAgentSessionWatchedExit +} from './structured-agent-session-stale-turn-verdict' type DeadGenerationSubmission = Pick< ReturnType[number], @@ -20,7 +22,6 @@ export type DeadGenerationJournal = { pendingSubmissions?: AgentSessionJournal['pendingSubmissions'] submissions?: () => DeadGenerationSubmission[] itemFence: AgentSessionJournal['itemFence'] - stopMarks: AgentSessionJournal['stopMarks'] } export type StructuredAgentSessionUnfinishedWork = { @@ -37,16 +38,16 @@ export function captureUnfinishedStructuredAgentSessionWork( } } -/** `exitProof`: see `unfinishedStructuredAgentSessionWorkWasInterrupted`. */ +/** `exit`: see `unfinishedStructuredAgentSessionWorkWasInterrupted`. */ export function hasUnfinishedStructuredAgentSessionWork( journal: DeadGenerationJournal, - exitProof?: AgentSessionDeathEvidence + exit?: StructuredAgentSessionWatchedExit ): boolean { const work = captureUnfinishedStructuredAgentSessionWork(journal) return ( work.hadUnsettledSubmissions || work.items.length > 0 || - provenUnverifiableTurnRevisions(journal.snapshot().items, exitProof, journal).length > 0 + watchedExitRevisions(journal.snapshot().items, exit, journal).length > 0 ) } @@ -54,7 +55,7 @@ export function unfinishedStructuredAgentSessionWorkWasInterrupted( before: StructuredAgentSessionUnfinishedWork, journal: DeadGenerationJournal, observedExitAt: number, - exitProof?: AgentSessionDeathEvidence + exit?: StructuredAgentSessionWatchedExit ): boolean { const currentSnapshot = journal.snapshot() if ( @@ -64,7 +65,7 @@ export function unfinishedStructuredAgentSessionWorkWasInterrupted( return true } // A turn the exited child left `unverifiable` (its stream closed first) was running when it went. - if (provenUnverifiableTurnRevisions(currentSnapshot.items, exitProof, journal).length > 0) { + if (watchedExitRevisions(currentSnapshot.items, exit, journal).length > 0) { return true } if ( From ed9fadc257a6ce26528ff125a496fd94b5b8bc20 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:15:08 -0700 Subject: [PATCH 202/269] test(native-chat): drop the duplicate itemFence on the fake that already had one --- .../structured-agent-session-unexpected-exit.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 1902c2fd2efe..4d099a26394d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -95,7 +95,6 @@ describe('provider-exit settlement', () => { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, - itemFence: () => undefined, snapshot: () => ({ items: [lifecycleItem('turn-1', 1, { state: 'running', startedAt: 1_000 })] }), From 16e84d08925a08fc3f131182972ee3216b3c7c91 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:22:30 -0700 Subject: [PATCH 203/269] test(claude, codex): an exit whose stdout ended first still reports as it always did The provider supervisor now ends Orca's stdout when the agent's ends, so on every crash EOF arrives before the exit is seen. Claude's and Codex's connections report nothing at EOF and report the exit, with its usual reason, once it is seen. --- ...laude-stream-json-connection-close.test.ts | 44 +++++++++++++++++++ .../codex/codex-app-server-connection.test.ts | 23 ++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/main/claude/claude-stream-json-connection-close.test.ts b/src/main/claude/claude-stream-json-connection-close.test.ts index 348601bbd489..62c284b1d67e 100644 --- a/src/main/claude/claude-stream-json-connection-close.test.ts +++ b/src/main/claude/claude-stream-json-connection-close.test.ts @@ -133,6 +133,50 @@ describe('Claude stream-json close ordering', () => { await expect(connection.close()).resolves.toBe(true) }) + // The provider supervisor ends Orca's stdout when Claude's ends, so EOF now comes before the exit. + it('reports an exit whose stdout ended first once the exit is seen, with its usual reason', async () => { + mocks.refresh.mockReset() + mocks.proveClaudeChildExit.mockReset() + mocks.refresh.mockResolvedValue(undefined) + mocks.proveClaudeChildExit.mockResolvedValue(true) + const child = fakeChild() + // The SDK's stream ends with the child's stdout. + const next = vi + .fn<() => Promise>>>() + .mockResolvedValue({ value: undefined, done: true }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection. + const queryImpl = ((params: Parameters[0]) => { + params.options?.spawnClaudeCodeProcess?.({ + command: 'claude', + args: [], + env: {}, + signal: new AbortController().signal + }) + return { + [Symbol.asyncIterator]: () => ({ next }) + } + }) as unknown as typeof query + const exits: string[] = [] + const connection = await openClaudeStreamJsonConnection( + { pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' }, + { onExit: (error, exit) => exits.push(`${exit?.expected}: ${error.message}`) }, + () => child, + queryImpl + ) + + child.stderr.write('session limit reached\n') + child.stdout.end() + await vi.waitFor(() => expect(next).toHaveBeenCalledOnce()) + await new Promise((resolve) => setImmediate(resolve)) + expect(exits).toEqual([]) + + child.emit('exit', 1, null) + await vi.waitFor(() => + expect(exits).toEqual(['false: claude stream-json exited (code 1): session limit reached']) + ) + await connection.close() + }) + it('returns an unproven close without waiting on a live output reader', async () => { mocks.refresh.mockReset() mocks.proveClaudeChildExit.mockReset() diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index 3e41a821dc65..a154ab15c76d 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -780,6 +780,29 @@ describe('openCodexAppServerConnection', () => { await connection.close() }) + // The provider supervisor ends Orca's stdout when Codex's ends, so EOF now comes before the exit. + it('reports an exit whose stdout ended first once the exit is seen, with its usual reason', async () => { + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const exits: string[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onExit: (error) => exits.push(error.message) }, + spawnImpl + ) + + child.stderr.write('codex crashed\n') + child.stdout.end() + await flushStreams() + expect(exits).toEqual([]) + child.emit('exit', 1, null) + child.emit('close', 1, null) + + expect(exits).toHaveLength(1) + expect(exits[0]).toContain('codex crashed') + await connection.close() + }) + it('reports one exit for a death that arrives through two listeners', async () => { const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) answerInitialize(child) From 75ddbbe47ffc9760fdff86555cc872eed7735ce3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:36:59 -0700 Subject: [PATCH 204/269] fix(acp): reopen a chat with session/load, as the common pattern does An agent that offers both now reloads its session instead of resuming it; the reattach window still discards what it replays except context usage. --- src/main/acp/acp-structured-acquire.ts | 11 +++--- .../acp/acp-structured-host-lifecycle.test.ts | 20 +++++----- .../acp-structured-host-start-aborts.test.ts | 4 +- src/main/acp/acp-structured-host-stop.test.ts | 2 +- .../acp/acp-structured-host.test-support.ts | 10 ++--- ...-structured-session-adapter-resume.test.ts | 38 +++++++++---------- 6 files changed, 42 insertions(+), 43 deletions(-) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 9c579cdfae2d..f73941d7ae52 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -1,9 +1,9 @@ // Making a reservation real for an ACP agent: spawn the child, record it before any handshake, // initialize with the client's file system and terminals off, then reattach the session this chat -// proved (`session/resume` where the agent offers it, else `session/load`) or start a new one. The -// journal already holds a reattached chat, so whatever the agent sends while it reattaches is not -// written, except context usage. The handshake is bounded: an agent that never answers fails the -// start instead of holding the chat's queue, and the acquire's abort signal stops it at any point. +// proved with `session/load` (`session/resume` only for an agent that cannot load) or start a new +// one. The journal already holds a reattached chat, so whatever the agent sends while it reattaches +// is not written, except context usage. The handshake is bounded: an agent that never answers fails +// the start instead of holding the chat's queue, and the acquire's abort signal stops it at any point. import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' @@ -234,8 +234,7 @@ export async function acquireAcpStructuredSession(input: { started = await runtime.start({ cwd: launch.cwd, mcpServers: [], - sessionId: resume.sessionId, - resumePreference: 'resume' + sessionId: resume.sessionId }) slot.reattaching = false attaching.apply(attaching.translator.finishLoad(now())) diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index 14e8ba6ff124..519c6c4e4627 100644 --- a/src/main/acp/acp-structured-host-lifecycle.test.ts +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -35,12 +35,12 @@ afterEach(async () => { await closeProviderTimelineRigs() }) -/** On every resume, Grok sends what its resume may carry: the saved `reply` marked as replay, and a +/** On every reopen, Grok sends what its load may carry: the saved `reply` marked as replay, and a * task the dead process left running, ended by the restart. */ -function resumes(reply: string, count: { resumes: number }) { +function loads(reply: string, count: { loads: number }) { return (agent: AcpScriptedAgent) => - agent.on('session/resume', (frame) => { - count.resumes += 1 + agent.on('session/load', (frame) => { + count.loads += 1 agent.notify('session/update', replyChunk('prompt:m1', reply, { isReplay: true })) agent.notify('x.ai/task_completed', { sessionId: PROVIDER_SESSION, @@ -53,13 +53,13 @@ function resumes(reply: string, count: { resumes: number }) { }) } -describe('resuming a Grok chat through the host', () => { - it('resumes a chat the journal holds and writes its exchange once', async () => { - const count = { resumes: 0 } +describe('reopening a Grok chat through the host', () => { + it('loads a chat the journal holds and writes its exchange once', async () => { + const count = { loads: 0 } let resumed = false const { host, fence, messages, exchange } = await openHostRig({ initialize: RESUMES, - script: resumes('hi', count), + script: loads('hi', count), deps: { resolveLaunch: launch(() => resumed) } }) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) @@ -70,7 +70,7 @@ describe('resuming a Grok chat through the host', () => { await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) await host.flushStreamedEvents(SESSION) - expect(count.resumes).toBe(1) + expect(count.loads).toBe(1) expect(await messages()).toEqual(['hello', 'hi']) const after = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items expect(after.filter((row) => row.itemId.includes('background-task'))).toEqual([]) @@ -84,7 +84,7 @@ describe('resuming a Grok chat through the host', () => { let resumed = false const { rig, host, fence, messages, exchange } = await openHostRig({ initialize: RESUMES, - script: resumes('complete saved reply', { resumes: 0 }), + script: loads('complete saved reply', { loads: 0 }), deps: { resolveLaunch: launch(() => resumed) } }) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) diff --git a/src/main/acp/acp-structured-host-start-aborts.test.ts b/src/main/acp/acp-structured-host-start-aborts.test.ts index a80e3b921d41..16731b5f4f87 100644 --- a/src/main/acp/acp-structured-host-start-aborts.test.ts +++ b/src/main/acp/acp-structured-host-start-aborts.test.ts @@ -164,7 +164,7 @@ describe('a Grok start the host aborts', () => { }) }) -/** A Grok chat the user closed; each later start resumes it. `hangsHandshake`: that child never +/** A Grok chat the user closed; each later start loads it. `hangsHandshake`: that child never * answers `initialize`. */ async function openClosedResumableChat(options: { hangsHandshake?: number } = {}) { let children = 0 @@ -175,7 +175,7 @@ async function openClosedResumableChat(options: { hangsHandshake?: number } = {} if (++children === options.hangsHandshake) { agent.on('initialize', () => {}) } - agent.on('session/resume', (frame) => + agent.on('session/load', (frame) => agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) ) }, diff --git a/src/main/acp/acp-structured-host-stop.test.ts b/src/main/acp/acp-structured-host-stop.test.ts index 9658384d6ee5..8dfa3973757f 100644 --- a/src/main/acp/acp-structured-host-stop.test.ts +++ b/src/main/acp/acp-structured-host-stop.test.ts @@ -53,7 +53,7 @@ describe('a Grok chat Stop', () => { await waitFor(() => expect(rig.child()).not.toBe(first)) const second = rig.child() await rig.frame('session/prompt') - expect(count.resumes).toBe(1) + expect(count.loads).toBe(1) expect(framesOf(second, 'session/new')).toHaveLength(0) expect(await messages()).toEqual(['hello', 'partial', 'again']) // Only the Stop's own note: no exit or cut-reply notice for a Stop the person asked for. diff --git a/src/main/acp/acp-structured-host.test-support.ts b/src/main/acp/acp-structured-host.test-support.ts index bd6dac1b387a..c021f4247a48 100644 --- a/src/main/acp/acp-structured-host.test-support.ts +++ b/src/main/acp/acp-structured-host.test-support.ts @@ -132,7 +132,7 @@ export async function openHostRig( return { rig, host, store, fence, messages, exchange } } -/** Grok's capabilities: it loads and resumes sessions. */ +/** Grok's capabilities: it loads and resumes sessions; Orca reopens with `session/load`. */ export const RESUMES = { agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } } } @@ -163,15 +163,15 @@ export function stop(host: StructuredAgentSessionHost, turnId?: string) { export const framesOf = (child: FakeAcpChild, method: string) => child.agent.frames.filter((frame) => frame.method === method) -/** A new Grok chat the host attached; Grok resumes its session on a later start, counting each. */ +/** A new Grok chat the host attached; Grok loads its session on a later start, counting each. */ export async function openAttachedHostRig(deps: Partial = {}) { - const count = { resumes: 0 } + const count = { loads: 0 } let resumed = false const rig = await openHostRig({ initialize: RESUMES, script: (agent) => - agent.on('session/resume', (frame) => { - count.resumes += 1 + agent.on('session/load', (frame) => { + count.loads += 1 agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) }), deps: { resolveLaunch: launch(() => resumed), ...deps } diff --git a/src/main/acp/acp-structured-session-adapter-resume.test.ts b/src/main/acp/acp-structured-session-adapter-resume.test.ts index 882a679426e4..a0ab62472bc8 100644 --- a/src/main/acp/acp-structured-session-adapter-resume.test.ts +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -28,7 +28,7 @@ const hello: AgentJournalMessageItem = { blocks: [{ type: 'text', text: 'hello' }] } -/** Grok's own capabilities: it loads and resumes sessions. */ +/** Grok's own capabilities: it loads and resumes sessions; Orca reopens with `session/load`. */ const RESUMES = { agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } } } const resume = { launch: { resume: { sessionId: PROVIDER_SESSION, replaceableKey: null } } } @@ -85,13 +85,13 @@ describe('the reattach window', () => { ...resume, initialize: RESUMES, script: (agent: AcpScriptedAgent) => - agent.on('session/resume', (frame) => { + agent.on('session/load', (frame) => { sendsUnmarkedWhileAttaching(agent) agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) }) } - it('writes nothing Grok sends while it resumes, even unmarked as replay', async () => { + it('writes nothing Grok sends while it loads, even unmarked as replay', async () => { const rig = await openAcpAdapterRig(resumesUnmarked) await rig.acquire() await rig.settle() @@ -102,7 +102,7 @@ describe('the reattach window', () => { const rig = await openAcpAdapterRig({ ...resumesUnmarked, script: (agent: AcpScriptedAgent) => - agent.on('session/resume', (frame) => { + agent.on('session/load', (frame) => { // An old reply with no end and no replay mark. agent.notify('session/update', replyChunk('prompt:old', 'stale text')) agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) @@ -118,8 +118,8 @@ describe('the reattach window', () => { }) }) -describe('the context meter across a resume', () => { - it('keeps the last reading the journal holds; the resume only refreshes the window', async () => { +describe('the context meter across a reopen', () => { + it('keeps the last reading the journal holds; the load only refreshes the window', async () => { const models = { currentModelId: 'grok-4.7', availableModels: [ @@ -130,8 +130,8 @@ describe('the context meter across a resume', () => { ...resume, initialize: RESUMES, script: (agent) => - // Grok replays no usage on resume; its answer carries only the models. - agent.on('session/resume', (frame) => + // A load that replays no usage; its answer carries only the models. + agent.on('session/load', (frame) => agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS, models }) ) }) @@ -160,12 +160,12 @@ describe('the context meter across a resume', () => { }) describe('reattaching a Grok chat the journal holds', () => { - it('resumes with session/resume and writes nothing Grok sends while it resumes', async () => { + it('loads with session/load though Grok also resumes, and writes nothing it replays', async () => { const rig = await openAcpAdapterRig({ ...resume, initialize: RESUMES, script: (agent) => - agent.on('session/resume', (frame) => { + agent.on('session/load', (frame) => { sendsWhileAttaching(agent, 'hi') agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) }) @@ -176,9 +176,9 @@ describe('reattaching a Grok chat the journal holds', () => { await rig.adapter.closeSession(SESSION) await rig.acquire({ fence: 2 }) await rig.settle() - expect(rig.sent('session/resume')).toHaveLength(1) - expect(rig.sent('session/load')).toHaveLength(0) - // No second user bubble, no stale task row, and no turn opened by the resume's own traffic. + expect(rig.sent('session/load')).toHaveLength(1) + expect(rig.sent('session/resume')).toHaveLength(0) + // No second user bubble, no stale task row, and no turn opened by the load's own traffic. expect((await rig.rig.rows()).map((row) => row.itemId)).toEqual(before) expect(await texts(rig)).toEqual(['hello', 'hi']) await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 2 })).resolves.toEqual({ @@ -233,7 +233,7 @@ describe('reattaching a Grok chat the journal holds', () => { ...resume, initialize: RESUMES, script: (agent) => - agent.on('session/resume', (frame) => { + agent.on('session/load', (frame) => { sendsWhileAttaching(agent, 'hi') agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) }) @@ -263,12 +263,12 @@ describe('reattaching a Grok chat the journal holds', () => { deferred.close() }) - it('starts a new session in place of a created one that session/resume reports missing', async () => { + it('starts a new session in place of a created one that session/load reports missing', async () => { const rig = await openAcpAdapterRig({ launch: { resume: { sessionId: 'never-saved', replaceableKey: 'acp:grok:never-saved' } }, initialize: RESUMES, script: (agent) => - agent.on('session/resume', (frame) => agent.fail(frame, -32002, 'Resource not found')) + agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Resource not found')) }) const acquired = await rig.acquire() expect(acquired.link).toMatchObject({ @@ -276,17 +276,17 @@ describe('reattaching a Grok chat the journal holds', () => { handle: { nativeId: PROVIDER_SESSION }, supersedesKey: 'acp:grok:never-saved' }) - // The failed resume left the translator taking prompts. + // The failed load left the translator taking prompts. await exchange(rig, 'hi', true) expect(await texts(rig)).toEqual(['hello', 'hi']) }) - it('keeps the slash commands Grok reports while it resumes', async () => { + it('keeps the slash commands Grok reports while it loads', async () => { const rig = await openAcpAdapterRig({ ...resume, initialize: RESUMES, script: (agent) => - agent.on('session/resume', (frame) => { + agent.on('session/load', (frame) => { agent.notify('session/update', { sessionId: PROVIDER_SESSION, update: { From ce62189dc8909a44c02bb17a48bc91622d7319d6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:46:06 -0700 Subject: [PATCH 205/269] fix(acp): drop the 60 s handshake bound; an abort fails the start's waits at once Neither common design bounds an ACP handshake: Close, Stop and quit end a start that never answers. The abort now also closes the connection, as a kill there does, so the start settles even before the child's exit is proven. The host-stopped start refusal only this bound produced goes with it; the idle sweep keeps its words. --- src/main/acp/acp-structured-acquire.ts | 34 ++++++---------- .../acp/acp-structured-host-lifecycle.test.ts | 31 +++------------ .../acp-structured-host-start-aborts.test.ts | 10 ++--- .../acp-structured-session-adapter-deps.ts | 2 - ...ructured-session-adapter-lifecycle.test.ts | 39 ++++++++++++------- .../structured-agent-session-failure-text.ts | 3 +- ...tructuredSession.launch-lifecycle.test.tsx | 17 -------- src/shared/agent-session-refusal-details.ts | 4 +- .../agent-session-refusal-reason-words.ts | 3 +- 9 files changed, 47 insertions(+), 96 deletions(-) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index f73941d7ae52..6dd839ea6cd7 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -2,8 +2,8 @@ // initialize with the client's file system and terminals off, then reattach the session this chat // proved with `session/load` (`session/resume` only for an agent that cannot load) or start a new // one. The journal already holds a reattached chat, so whatever the agent sends while it reattaches -// is not written, except context usage. The handshake is bounded: an agent that never answers fails -// the start instead of holding the chat's queue, and the acquire's abort signal stops it at any point. +// is not written, except context usage. The handshake has no time bound: the acquire's abort signal +// (Close, Stop, quit) stops it at any point. import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' @@ -45,17 +45,6 @@ import { RequestPermissionResponseSchema } from './generated/acp-protocol.genera const ACP_RESOURCE_NOT_FOUND = -32002 /** Frames an agent may send before its session exists; past this they are dropped. */ const MAX_EARLY_FRAMES = 2_048 -/** How long the agent has to answer the handshake, reattach or create its session, and take saved - * options back. A load-only agent replays the whole conversation, so this is generous. */ -export const ACP_STARTUP_TIMEOUT_MS = 60_000 - -export class AcpStartupTimeoutError extends Error { - constructor(agentName: string, timeoutMs: number) { - super(`${agentName} did not finish starting within ${Math.round(timeoutMs / 1_000)} seconds`) - this.name = 'AcpStartupTimeoutError' - } -} - export function acpAgentName(agent: string): string { return isTuiAgent(agent) ? TUI_AGENT_DISPLAY_NAMES[agent] : agent } @@ -164,6 +153,14 @@ export async function acquireAcpStructuredSession(input: { input.onConnectionLost(session, error) } }) + // An abort fails whatever the agent left unanswered at once, as a kill does, whether or not the + // child's exit is proven yet. + const abandon = () => + runtime.close(new Error(`${acpAgentName(spec.agent)} was closed while starting`)) + acquire.signal?.addEventListener('abort', abandon, { once: true }) + if (acquire.signal?.aborted) { + abandon() + } runtime.subscribe((event: AcpSessionEvent) => whenLane( () => @@ -213,12 +210,6 @@ export async function acquireAcpStructuredSession(input: { } await identity.onSpawned(child.pid) const agentName = acpAgentName(spec.agent) - const startupTimeoutMs = deps.startupTimeoutMs ?? ACP_STARTUP_TIMEOUT_MS - // Closing the connection fails whichever request the agent left unanswered. - const startupDeadline = setTimeout( - () => runtime.close(new AcpStartupTimeoutError(agentName, startupTimeoutMs)), - startupTimeoutMs - ) try { await runtime.initialize() const resume = launch.resume @@ -317,11 +308,8 @@ export async function acquireAcpStructuredSession(input: { 'notSignedIn' ) } - if (error instanceof AcpStartupTimeoutError) { - throw new AgentSessionAcquisitionRefusal(error.message, 'hostStopped') - } throw error } finally { - clearTimeout(startupDeadline) + acquire.signal?.removeEventListener('abort', abandon) } } diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index 519c6c4e4627..776ca7a9fa6e 100644 --- a/src/main/acp/acp-structured-host-lifecycle.test.ts +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -171,8 +171,7 @@ describe('a Grok crash whose exit is not proven yet', () => { describe('closing or stopping a Grok chat while it starts', () => { it('keeps a failed start whose child is not proven gone, so a later close asks it again', async () => { const { rig, host } = await openHostRig({ - script: (agent) => agent.on('initialize', () => {}), - deps: { startupTimeoutMs: 30 } + script: (agent) => agent.on('initialize', () => {}) }) const attaching = host.attach(CALLER, attachParams()).catch((error: unknown) => error) await rig.frame('initialize') @@ -192,32 +191,14 @@ describe('closing or stopping a Grok chat while it starts', () => { expect(await rig.adapter.closeSession(SESSION)).toBe(true) }) - it('refuses a new chat whose start never answered with why, worded like a host-stopped start', async () => { - const { rig, host } = await openHostRig({ - script: (agent) => agent.on('initialize', () => {}), - deps: { startupTimeoutMs: 30 } - }) - const attached = await host.attach(CALLER, attachParams()) - expect(attached).toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - details: { reason: 'hostStopped' }, - message: expect.stringContaining('Grok never finished starting, so Orca stopped it.') - } - }) - expect(rig.child().exited).toBe(true) - }) - it('lets a Stop reach a child Grok never finished initializing', async () => { const { rig, host } = await openHostRig({ - script: (agent) => agent.on('initialize', () => {}), - deps: { startupTimeoutMs: 60_000 } + script: (agent) => agent.on('initialize', () => {}) }) const attaching = host.attach(CALLER, attachParams()) await rig.frame('initialize') const stopping = host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) - // Long before the startup bound, and with no close behind it. + // With no close behind it. await waitFor(() => expect(rig.child().exited).toBe(true)) expect((await attaching).ok).toBe(false) expect(await stopping).toMatchObject({ ok: true }) @@ -232,8 +213,7 @@ describe('closing or stopping a Grok chat while it starts', () => { if (spawns > 1) { agent.on('initialize', () => {}) } - }, - deps: { startupTimeoutMs: 60_000 } + } }) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) // The chat closes; the next send has to start Grok again, and that start never answers. @@ -261,8 +241,7 @@ describe('closing or stopping a Grok chat while it starts', () => { it('leaves a start alone for a Stop that names a turn of a child already gone', async () => { const { rig, host } = await openHostRig({ - script: (agent) => agent.on('initialize', () => {}), - deps: { startupTimeoutMs: 60_000 } + script: (agent) => agent.on('initialize', () => {}) }) const attaching = host.attach(CALLER, attachParams()) await rig.frame('initialize') diff --git a/src/main/acp/acp-structured-host-start-aborts.test.ts b/src/main/acp/acp-structured-host-start-aborts.test.ts index 16731b5f4f87..216a495e1bc9 100644 --- a/src/main/acp/acp-structured-host-start-aborts.test.ts +++ b/src/main/acp/acp-structured-host-start-aborts.test.ts @@ -64,12 +64,11 @@ function duringOwnerProbe(host: StructuredAgentSessionHost, during: () => void): describe('a Grok start the host aborts', () => { it('quits promptly while Grok never answers its handshake, and stops the child', async () => { const { rig, host } = await openHostRig({ - script: (agent) => agent.on('initialize', () => {}), - deps: { startupTimeoutMs: 60_000 } + script: (agent) => agent.on('initialize', () => {}) }) const attaching = host.attach(CALLER, attachParams()) await rig.frame('initialize') - // Well inside the start's own bound: the quit is what ends it. + // The start has no bound of its own: the quit is what ends it. expect(await within(host.flushAllStreamedEvents({ trigger: 'quit' }), 2_000)).not.toBe(STALLED) expect((await attaching).ok).toBe(false) expect(rig.child().exited).toBe(true) @@ -99,8 +98,7 @@ describe('a Grok start the host aborts', () => { it('never launches Grok for a chat closed while its attach was still reconciling', async () => { const { rig, host } = await openHostRig({ - script: (agent) => agent.on('initialize', () => {}), - deps: { startupTimeoutMs: 60_000 } + script: (agent) => agent.on('initialize', () => {}) }) let closing: Promise | undefined duringOwnerProbe(host, () => { @@ -179,7 +177,7 @@ async function openClosedResumableChat(options: { hangsHandshake?: number } = {} agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) ) }, - deps: { resolveLaunch: launch(() => resumed), startupTimeoutMs: 60_000 } + deps: { resolveLaunch: launch(() => resumed) } }) expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) resumed = true diff --git a/src/main/acp/acp-structured-session-adapter-deps.ts b/src/main/acp/acp-structured-session-adapter-deps.ts index 03498de5e1c8..7cbb863144dd 100644 --- a/src/main/acp/acp-structured-session-adapter-deps.ts +++ b/src/main/acp/acp-structured-session-adapter-deps.ts @@ -39,8 +39,6 @@ export type AcpStructuredSessionAdapterDeps = { cancelTimeoutMs?: number /** Bounds a Stop's wait for the agent to end its turn; past it the child is closed. */ stopGraceMs?: number - /** Bounds the handshake and session load or creation; past it the start fails. */ - startupTimeoutMs?: number /** Bounds a pick's wait for the agent's answer; past it the pick fails. */ optionWriteTimeoutMs?: number isWindowsProcessStartTimeAvailable?: () => boolean diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index b5777d9b09be..b5bb712f1776 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -329,25 +329,34 @@ describe('ACP startup that never answers', () => { expect(rig.lifecycle).toEqual([]) }) - it('fails the start once the handshake outlasts its bound, and stops the child', async () => { - const rig = await openAcpAdapterRig({ - script: (agent) => agent.on('session/new', () => {}), - deps: { startupTimeoutMs: 20 } - }) - const failure = await rig.acquire().catch((error: unknown) => error) - expect(failure).toMatchObject({ - name: 'AgentSessionAcquisitionRefusal', - reason: 'hostStopped', - message: 'Grok did not finish starting within 0 seconds' - }) - expect(rig.child().closes).toBe(1) - await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + it('lets a handshake Grok never answers run on until the start is aborted', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const rig = await openAcpAdapterRig({ + script: (agent) => agent.on('session/new', () => {}) + }) + const start = new AbortController() + const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error) + let settled = false + void failed.then(() => { + settled = true + }) + await rig.settle() + vi.advanceTimersByTime(30 * 60_000) + await rig.settle() + expect(settled).toBe(false) + expect(rig.child().closes).toBe(0) + start.abort() + expect(await failed).toBeInstanceOf(Error) + expect(rig.child().closes).toBeGreaterThan(0) + } finally { + vi.useRealTimers() + } }) it('keeps the child of a failed start until its exit is proven, and starts no other meanwhile', async () => { const rig = await openAcpAdapterRig({ - script: (agent) => agent.on('initialize', () => {}), - deps: { startupTimeoutMs: 15 } + script: (agent) => agent.on('initialize', () => {}) }) const start = new AbortController() const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index 64eb196ed536..c0c2172230dc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -26,8 +26,7 @@ const TYPED_START_REFUSALS = [ 'historyTooLarge', 'managedAccountEnvOverride', 'accountSwitchInProgress', - 'managedAccountUnsupported', - 'hostStopped' + 'managedAccountUnsupported' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx index 6bde4ea8da2d..644c1c657500 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx @@ -116,23 +116,6 @@ describe('NativeChatStructuredSession launch lifecycle', () => { expect(screen.queryByText(/agent_session_/)).toBeNull() }) - it('says the agent never finished starting when Orca stopped a start that never answered', () => { - mocks.launchLifecycle = 'failed' - mocks.launchFailure = { - kind: 'refused', - code: 'agent_session_operation_invalid', - details: { reason: 'hostStopped' } - } - render(sessionView()) - - expect( - screen.getByText( - 'Chat could not be started. Codex never finished starting, so Orca stopped it.' - ) - ).toBeTruthy() - expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy() - }) - it("keeps a step the Retry doesn't take, and drops one it does", () => { mocks.launchLifecycle = 'failed' mocks.launchFailure = { diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 2ca9bd1960d1..b56ca5fbeaaa 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -52,9 +52,7 @@ export const AGENT_SESSION_REFUSAL_REASONS = { /** A Claude account is added in WSL and no Windows one is selected, which a chat can't run under. */ 'managedAccountUnsupported', /** The agent started, then Orca could not open the chat's conversation for it. */ - 'attachFailed', - /** The agent never finished starting, so Orca stopped it. */ - 'hostStopped' + 'attachFailed' ], agent_session_ownership_unknown: [ 'sessionNotAttached', diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index 715cbd1531cb..5f044eb8b42a 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -93,8 +93,7 @@ const REASON_WORDS = { managedAccountEnvOverride: { fact: 'managedAccountEnvOverride', action: 'actFirst' }, accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' }, managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, - attachFailed: codeWords('retry'), - hostStopped: { fact: 'hostStopped', action: 'retry' } + attachFailed: codeWords('retry') }, agent_session_ownership_unknown: { sessionNotAttached: codeWords('retry'), From 275455dbcb674fadbe21776c98cb0a6b1b989442 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:49:43 -0700 Subject: [PATCH 206/269] fix(acp): a Stop naming an ended turn follows Claude's rule It still stops nothing while another turn is live, but in the gap before a follow-up's turn opens, which no client can name, it now stops what is in flight and the session ends, as a Claude Stop does. --- src/main/acp/acp-structured-host-stop.test.ts | 12 ++++++------ .../acp-structured-session-adapter-lifecycle.test.ts | 8 ++++---- src/main/acp/acp-structured-session-adapter.ts | 7 ++++--- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/main/acp/acp-structured-host-stop.test.ts b/src/main/acp/acp-structured-host-stop.test.ts index 8dfa3973757f..7fe35c5b2e71 100644 --- a/src/main/acp/acp-structured-host-stop.test.ts +++ b/src/main/acp/acp-structured-host-stop.test.ts @@ -143,7 +143,7 @@ describe('a Grok chat Stop naming a turn that has ended', () => { await host.close(SESSION, 'user-close') }) - it('stops nothing in the gap before Grok echoes the next prompt', async () => { + it('ends Grok in the gap before it echoes the next prompt, as a Claude Stop does', async () => { const { rig, host } = await openAttachedHostRig() const { agent } = rig.child() await send(host, 'old') @@ -152,13 +152,13 @@ describe('a Grok chat Stop naming a turn that has ended', () => { agent.reply(old, { stopReason: 'end_turn' }) await rig.settle() await send(host, 'new') - await rig.frame('session/prompt', 1) + const next = await rig.frame('session/prompt', 1) + agent.on('session/cancel', () => agent.reply(next, { stopReason: 'cancelled' })) await host.flushStreamedEvents(SESSION) const stopped = await stop(host, providerTurnId(promptIdOf(old), PROVIDER_SESSION)) - expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) - await rig.settle() - expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(0) - expect(rig.child().exited).toBe(false) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(rig.child().exited).toBe(true)) + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(1) await host.close(SESSION, 'user-close') }) }) diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index b5bb712f1776..b58f825d76de 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -59,7 +59,7 @@ describe('ACP Stop names a turn', () => { expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['old', 'new']) }) - it('a Stop naming an ended turn during the gap before the next prompt opens stops nothing', async () => { + it('a Stop naming an ended turn during the gap before the next prompt opens stops that prompt', async () => { const rig = await openAcpAdapterRig() await rig.acquire() await sendHello(rig, 'old') @@ -70,7 +70,7 @@ describe('ACP Stop names a turn', () => { await sendHello(rig, 'new') await rig.frame('session/prompt', 1) await rig.settle() - // The journal shows no turn running: Grok has not echoed the new prompt yet. + // The journal shows no turn running: Grok has not echoed the new prompt yet, as with Claude. await expect( rig.adapter.cancelTurn({ sessionId: SESSION, @@ -78,8 +78,8 @@ describe('ACP Stop names a turn', () => { turnId: turnIdOf('prompt:old'), resolveLiveTurnId: () => null }) - ).resolves.toEqual({ cancelled: false, refusal: { turnNotRunning: true } }) - expect(rig.sent('session/cancel')).toHaveLength(0) + ).resolves.toEqual({ cancelled: true }) + expect(rig.sent('session/cancel')).toHaveLength(1) }) it('a Stop naming the turn running now stops it and withdraws the steer waiting on its cancel', async () => { diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index c9494fe48f24..d068570266fd 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -147,10 +147,11 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = async (input) => { const session = this.live(input.sessionId) - // The Stop ends the child unless it is declined here, so a Stop naming a turn that has since - // ended must stop nothing newer: not the turn running now, nor the follow-ups behind it. + // The Stop ends the child unless it is declined here. Claude's rule: a Stop naming an ended turn + // while another one is live stops nothing; in the gap before a follow-up's turn opens, which no + // client can name, it stops what is in flight. const liveTurnId = input.resolveLiveTurnId?.() ?? session.lane.openTurnId - if (input.turnId !== undefined && input.turnId !== liveTurnId) { + if (input.turnId !== undefined && liveTurnId !== null && input.turnId !== liveTurnId) { return { cancelled: false, refusal: { turnNotRunning: true } } } const withdrew = session.turns.withdrawSteers() From c832af5de07700e53fd69013c4dc109440e9618e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:53:47 -0700 Subject: [PATCH 207/269] fix(native-chat): a close no longer re-asks a failed start's unproven child Neither common design retries that stop at Close, and Orca's Claude contract re-asks only at the next start and at quit. The ACP adapter keeps the child until its exit is proven and asks it again there, as Claude does. --- .../acp/acp-structured-host-lifecycle.test.ts | 17 ++- ...agent-session-close-unproven-owner.test.ts | 122 ------------------ ...red-agent-session-conversation-lifetime.ts | 21 --- 3 files changed, 11 insertions(+), 149 deletions(-) delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-close-unproven-owner.test.ts diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index 776ca7a9fa6e..159be2f820ae 100644 --- a/src/main/acp/acp-structured-host-lifecycle.test.ts +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -169,7 +169,7 @@ describe('a Grok crash whose exit is not proven yet', () => { }) describe('closing or stopping a Grok chat while it starts', () => { - it('keeps a failed start whose child is not proven gone, so a later close asks it again', async () => { + it('keeps a failed start whose child is not proven gone; a close leaves it, the next start asks it again', async () => { const { rig, host } = await openHostRig({ script: (agent) => agent.on('initialize', () => {}) }) @@ -179,16 +179,21 @@ describe('closing or stopping a Grok chat while it starts', () => { child.close = vi.fn(async () => false) await host.close(SESSION, 'user-close') expect(await attaching).toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) - expect(child.exited).toBe(false) - expect(await rig.adapter.closeSession(SESSION)).toBe(false) child.close = vi.fn(async () => { child.exit() return true }) await host.close(SESSION, 'user-close') - expect(child.close).toHaveBeenCalled() - expect(child.exited).toBe(true) - expect(await rig.adapter.closeSession(SESSION)).toBe(true) + expect(child.close).not.toHaveBeenCalled() + expect(child.exited).toBe(false) + // As Claude's: the next start asks the child again before it spawns another. + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: hello }), + body: hello + }) + await waitFor(() => expect(child.exited).toBe(true)) + await waitFor(() => expect(rig.child()).not.toBe(child)) + await host.close(SESSION, 'user-close') }) it('lets a Stop reach a child Grok never finished initializing', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-unproven-owner.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-unproven-owner.test.ts deleted file mode 100644 index 7536eb723c44..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-unproven-owner.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -// A close re-asks the adapter for a failed start's owner the record still names -// (`releaseUnprovenOwner`). An adapter acts only on a child it still holds in memory: one it holds -// nothing for stops nothing, spawns nothing and reports nothing (as after an Orca restart, where -// the record names an old pid); one still holding the child a failed cleanup left closes it again. - -import { describe, expect, it, vi } from 'vitest' -import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' -import type { ClaudeStructuredSessionEvent } from '../../claude/claude-structured-session-state' -import { - adapterFor as claudeAdapterFor, - fakeClaude, - identityFor as claudeIdentityFor, - recordingJournalSink -} from '../../claude/claude-structured-session-test-support' -import { CodexStructuredSessionAdapter } from '../../codex/codex-structured-session-adapter' -import { - adapterFor as codexAdapterFor, - fakeCodex, - identityFor as codexIdentityFor -} from '../../codex/codex-structured-session-adapter-fixture' -import type { CodexStructuredSessionEvent } from '../../codex/codex-structured-session-state' -import { claudeAndCodexRouter } from './structured-agent-session-adapter-router-test-support' - -describe('a close re-asking for an owner the adapter never held', () => { - it('stops nothing for Claude', async () => { - const openConnection = vi.fn(async () => { - throw new Error('no Claude may start') - }) - const onEvent = vi.fn() - const persistHandle = vi.fn(async () => {}) - const adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => { - throw new Error('no Claude may launch') - }, - openConnection, - readProcessStartTime: async () => 1_700_000_000_000, - onEvent, - persistHandle - }) - await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) - expect(openConnection).not.toHaveBeenCalled() - expect(onEvent).not.toHaveBeenCalled() - expect(persistHandle).not.toHaveBeenCalled() - }) - - it('stops nothing for Codex', async () => { - const openConnection = vi.fn(async () => { - throw new Error('no Codex may start') - }) - const onEvent = vi.fn() - const adapter = new CodexStructuredSessionAdapter({ - resolveLaunch: async () => { - throw new Error('no Codex may launch') - }, - openConnection, - readProcessStartTime: async () => 1_700_000_000_000, - onEvent - }) - await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) - expect(openConnection).not.toHaveBeenCalled() - expect(onEvent).not.toHaveBeenCalled() - }) -}) - -describe('a close re-asking for an owner whose failed cleanup the adapter still holds', () => { - it('closes the Claude child again, as a close Orca asked for, and keeps its handle', async () => { - const claude = fakeClaude() - const events: ClaudeStructuredSessionEvent[] = [] - const persisted: unknown[] = [] - const router = claudeAndCodexRouter( - { - claude: claudeAdapterFor(claude, {}, events, persisted), - // The router's fan-out asks it too; it holds nothing. - codex: codexAdapterFor(fakeCodex()) - }, - async () => {} - ) - await router.acquire({ - identity: claudeIdentityFor(), - fence: 7, - spawnToken: 'spawn-9', - events: recordingJournalSink() - }) - const connection = claude.connections[0]! - const close = vi.spyOn(connection, 'close').mockResolvedValueOnce(false) - // The host could not commit the start, and its cleanup could not prove the child gone. - expect(await router.releaseAcquisition({ sessionId: 'session-1' }).catch(() => false)).toBe( - false - ) - const before = { events: events.length, persisted: persisted.length } - expect(await router.releaseAcquisition({ sessionId: 'session-1' })).toBe(true) - expect(close).toHaveBeenCalledTimes(2) - // The handle of the conversation the child ran, so the next send resumes it. - expect(events.slice(before.events).map((event) => event.type)).toEqual(['ended', 'handle']) - expect(events[before.events]).toMatchObject({ cause: 'requested-close' }) - expect(persisted).toHaveLength(before.persisted + 1) - }) - - it('closes the Codex child again, as a close Orca asked for', async () => { - const codex = fakeCodex() - const events: CodexStructuredSessionEvent[] = [] - const router = claudeAndCodexRouter( - { - claude: claudeAdapterFor(fakeClaude()), - codex: codexAdapterFor(codex, {}, events) - }, - async () => {} - ) - await router.acquire({ identity: codexIdentityFor('session-1'), fence: 7, spawnToken: 's-9' }) - const connection = codex.connections[0]! - const close = vi.spyOn(connection, 'close').mockResolvedValueOnce(false) - expect(await router.releaseAcquisition({ sessionId: 'session-1' }).catch(() => false)).toBe( - false - ) - const before = events.length - expect(await router.releaseAcquisition({ sessionId: 'session-1' })).toBe(true) - expect(close).toHaveBeenCalledTimes(2) - expect(events.slice(before).filter((event) => event.type === 'ended')).toMatchObject([ - { cause: 'requested-close' } - ]) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 51f1ee031201..94249402b212 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -76,26 +76,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { ) } - /** A start that failed with its child not proven gone leaves no child here, only the record's - * owner, so the close asks the adapter again; it stops only a child it still holds, for every - * agent. Its answer proves nothing for the record: the lease probe does. The close awaits it, - * bounded by the adapter's own kill ladder, and a failure is logged, never gating the close. */ - const releaseUnprovenOwner = async (sessionId: string): Promise => { - const lease = deps().store.getRecord(sessionId)?.lease - if (sessions.get(sessionId)?.child || !lease?.ownerProcess || lease.deathEvidence !== null) { - return - } - await deps() - .adapter.releaseAcquisition?.({ sessionId }) - .catch((error: unknown) => - deps().logger.warn("stopping a failed start's agent for a close failed", { - scope: 'close-unproven-owner', - sessionId, - error - }) - ) - } - const idleSweep = new StructuredAgentSessionIdleSweep({ sessions, serialize, @@ -191,7 +171,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { await abandonQueuedStructuredAgentSessionMessages(deps(), sessionId, session.journal) } await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) - await releaseUnprovenOwner(sessionId) await closeConversation(sessionId) }) } From fe8108c65a044462d0f67ae6a85e18a1492a0225 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:57:15 -0700 Subject: [PATCH 208/269] fix(acp): a message sent during a turn the agent began itself goes at once Both common designs send it straight to the agent with no cancel; only Orca's own running prompt is steered (cancelled, then re-prompted). --- src/main/acp/acp-structured-steer.test.ts | 60 +++++++---------------- src/main/acp/acp-structured-turns.ts | 38 +++----------- 2 files changed, 25 insertions(+), 73 deletions(-) diff --git a/src/main/acp/acp-structured-steer.test.ts b/src/main/acp/acp-structured-steer.test.ts index c6dcf3580a00..7aa996d9d39a 100644 --- a/src/main/acp/acp-structured-steer.test.ts +++ b/src/main/acp/acp-structured-steer.test.ts @@ -162,47 +162,27 @@ describe('a send while a Grok prompt runs', () => { }) describe('a send while a turn Grok began itself runs', () => { - async function steerBehindGrokTurn() { + it('goes to Grok at once, with no cancel of that turn', async () => { const rig = await openAcpAdapterRig() await rig.acquire() - rig.child().agent.notify('session/update', replyChunk(GROK_TURN, 'Build finished; now')) - await rig.settle() - await sendHello(rig, 'steer') - await rig.settle() - expect(rig.sent('session/cancel')).toHaveLength(1) - expect(rig.sent('session/prompt')).toHaveLength(0) - return rig - } - - it('withdraws the steer a Stop reaches while it waits for that turn to end', async () => { - const rig = await steerBehindGrokTurn() - await expect(rig.adapter.cancelTurn({ sessionId: ADAPTER_SESSION, fence: 1 })).resolves.toEqual( - { cancelled: true } - ) - endsGrokTurn(rig.child().agent) + const { agent } = rig.child() + agent.notify('session/update', replyChunk(GROK_TURN, 'Build finished; now')) await rig.settle() - expect(rig.sent('session/prompt')).toHaveLength(0) - expect(rig.settled).toEqual([ - expect.objectContaining({ - clientMessageId: 'steer', - state: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED - }) - ]) - }) - - it('rejects the waiting steer as never sent when Grok exits', async () => { - const rig = await steerBehindGrokTurn() - rig.child().exit() + await sendHello(rig, 'during') + const prompt = await rig.frame('session/prompt') + expect(rig.sent('session/cancel')).toHaveLength(0) + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'turn_completed', prompt_id: GROK_TURN, stop_reason: 'end_turn' } + }) + agent.notify('session/update', replyChunk(promptIdOf(prompt), 'on it')) + agent.reply(prompt, { stopReason: 'end_turn' }) await rig.settle() - expect(rig.sent('session/prompt')).toHaveLength(0) expect(rig.settled).toEqual([ - expect.objectContaining({ - clientMessageId: 'steer', - state: 'rejected', - reason: 'Grok stopped before this message was sent.' - }) + expect.objectContaining({ clientMessageId: 'during', providerIdentity: expect.anything() }) ]) + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.map((turn) => turn.state)).toEqual(['completed', 'completed']) }) }) @@ -310,21 +290,19 @@ describe('Steer on a Grok card, through the host', () => { await host.close(SESSION, 'user-close') }) - it('cancels a turn Grok began itself on Steer, and sends the card once that turn ends', async () => { + it('sends the card at once on Steer during a turn Grok began itself, with no cancel', async () => { const rig = await openAttachedHostRig({ stopGraceMs: 20 }) const child = rig.rig.child() child.agent.notify('session/update', replyChunk(GROK_TURN, 'Build finished; now')) await rig.rig.settle() const { sendNow, sends } = await queueCard(rig) expect(await sendNow()).toMatchObject({ ok: true }) - await waitFor(() => expect(framesOf(child, 'session/cancel')).toHaveLength(1)) - // Not into Grok's own queue behind its turn, where Orca could no longer withdraw it. - expect(framesOf(child, 'session/prompt')).toHaveLength(0) - endsGrokTurn(child.agent) const steered = await rig.rig.frame('session/prompt') + expect(framesOf(child, 'session/cancel')).toHaveLength(0) + endsGrokTurn(child.agent) child.agent.notify('session/update', replyChunk(promptIdOf(steered), 'on it')) await waitFor(async () => expect(await sends()).toMatchObject([{ state: 'accepted' }])) - expect(framesOf(child, 'session/cancel')).toHaveLength(1) + expect(framesOf(child, 'session/cancel')).toHaveLength(0) expect(child.exited).toBe(false) await rig.host.close(SESSION, 'user-close') }) diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index abd9264a0b7b..72aee2f2c1ee 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -1,9 +1,8 @@ -// The sends of one ACP session. ACP runs one prompt at a time, so a message sent while a prompt runs -// steers: the running prompt is cancelled (the session stays) and the message goes as the next -// prompt once the agent answers the cancel. A turn the agent began itself is the running reply too, -// as it is for a Stop: it is cancelled, and the message goes once that turn ends. A steer waits here -// until then; one behind it cancels it in turn, so the last one runs, and a Stop withdraws what -// waits. Each send is settled exactly once: +// The sends of one ACP session. ACP runs one prompt at a time, so a message sent while Orca's prompt +// runs steers: that prompt is cancelled (the session stays) and the message goes as the next prompt +// once the agent answers the cancel. A steer waits here until then; one behind it cancels it in turn, +// so the last one runs, and a Stop withdraws what waits. A turn the agent began itself is not Orca's +// to cut short: a message sent during it goes to the agent at once. Each send is settled exactly once: // accepted when the agent's first event for its turn (or its answer) arrives, rejected when the // agent refused it or it never left Orca, unknown when the agent died with it or its connection // broke before it answered. @@ -60,9 +59,7 @@ export type AcpStructuredTurnsDeps = { export class AcpStructuredTurns { private active: Send | null = null - /** The agent's own turn the steers wait to leave; null while none is waited on. */ - private awaitedTurn: string | null = null - /** Steers waiting for the prompt or turn ahead of them to answer its cancel. */ + /** Steers waiting for the prompt ahead of them to answer its cancel. */ private readonly steers: Send[] = [] private readonly unsettled = new Set() private readonly idleWaiters = new Set<() => void>() @@ -89,16 +86,6 @@ export class AcpStructuredTurns { this.cancelForSteer() return } - if (this.awaitedTurn !== null) { - this.steers.push(send) - return - } - const theirs = this.deps.lane.openTurnId - if (theirs !== null) { - this.steers.push(send) - this.sendAfter(theirs) - return - } this.start(send) } @@ -193,19 +180,6 @@ export class AcpStructuredTurns { } } - /** Cancels the agent's own turn once, and sends the next steer when that turn ends. */ - private sendAfter(turnId: string): void { - this.awaitedTurn = turnId - this.cancelForSteer() - void this.deps.lane.whenTurnLeaves(turnId).then(() => { - this.awaitedTurn = null - const next = this.ended ? undefined : this.steers.shift() - if (next) { - this.start(next) - } - }) - } - /** Answers the agent's open requests cancelled and ends the running prompt. One the agent never * answers closes the connection past the runtime's bound, which ends the session. */ private cancelForSteer(): void { From 6143fb417997b231375d1a2a8fd2e3b1241a8436 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:58:37 -0700 Subject: [PATCH 209/269] test(native-chat): dismiss-all through a remote client's audience keeps a newer Orca's offer Uses an audience production sends (one that cannot show every agent), per review. --- .../structured-agent-session-restart-offer-newer-orca.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts index 689a33246a8f..932bd700fbf7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts @@ -156,7 +156,8 @@ it('still offers a damaged chat, and files its failure when acting on it', async it.each([ ['after the dialog listed the offers', true, undefined], ['on a host that has not listed them yet', false, undefined], - ['for a client shown every agent', false, () => true] + // A remote client that cannot show every agent dismisses through its audience. + ['for a client shown only Codex', false, (agent: string) => agent === 'codex'] ])( "dismisses every listed offer and leaves a newer Orca's hidden one as it was, %s", async (_when, listFirst, audience) => { From d717abf6916a9b218a795f2cbd7d6d056ffe4a99 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:58:56 -0700 Subject: [PATCH 210/269] fix(acp): launch Grok as `grok agent stdio`, without the update and leader flags The common pattern passes neither --no-auto-update, --no-leader nor GROK_DISABLE_AUTOUPDATER; full access still adds --always-approve. --- src/main/acp/acp-launch-specs.ts | 13 +++---------- .../acp-structured-launch-resolution.test.ts | 5 ++--- .../acp-structured-session-adapter.test.ts | 19 ++++--------------- 3 files changed, 9 insertions(+), 28 deletions(-) diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 3e21bdb6e8a2..88ff46659503 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -32,16 +32,9 @@ export type AcpLaunchSpec = { const GROK_LAUNCH_SPEC: AcpLaunchSpec = { agent: 'grok', command: 'grok', - // Orca owns this child's lifetime: no self-update mid-chat and no shared leader process it - // cannot stop. `--always-approve` only for full access, as the user's setting chooses. - args: ({ fullAccess }) => [ - '--no-auto-update', - 'agent', - '--no-leader', - ...(fullAccess ? ['--always-approve'] : []), - 'stdio' - ], - env: { GROK_DISABLE_AUTOUPDATER: '1' }, + // `--always-approve` only for full access, as the user's setting chooses. + args: ({ fullAccess }) => ['agent', ...(fullAccess ? ['--always-approve'] : []), 'stdio'], + env: {}, dialect: GROK_ACP_DIALECT, loginCommand: ['grok', 'login'], accountHomeVariable: 'GROK_HOME', diff --git a/src/main/acp/acp-structured-launch-resolution.test.ts b/src/main/acp/acp-structured-launch-resolution.test.ts index 220a3fb7ffd2..e9709ef5c4dd 100644 --- a/src/main/acp/acp-structured-launch-resolution.test.ts +++ b/src/main/acp/acp-structured-launch-resolution.test.ts @@ -43,19 +43,18 @@ function resolver(record: AgentSessionRecord, fullAccess = false) { } describe('ACP launch resolution', () => { - it('pins the record account home, never self-updates, and searches the agent install dir', async () => { + it('pins the record account home and searches the agent install dir', async () => { const { resolve, searched } = resolver(grokRecord()) const launch = await resolve({ identity }) expect(launch).toMatchObject({ command: '/resolved/grok', - args: ['--no-auto-update', 'agent', '--no-leader', 'stdio'], + args: ['agent', 'stdio'], cwd: '/repo/worktree', fullAccess: false, resume: null }) expect(launch.env).toMatchObject({ GROK_HOME: '/home/user/.grok-work', - GROK_DISABLE_AUTOUPDATER: '1', GROK_EXTRA: '1' }) expect(searched[0]).toContain('/home/user/.grok-work/bin') diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 9648808285d8..8955a8d0773b 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -378,20 +378,9 @@ describe('ACP structured session adapter: close and exit', () => { }) describe('Grok launch spec', () => { - it('never self-updates and asks for always-approve only with full access', () => { - expect(GROK.args({ fullAccess: false })).toEqual([ - '--no-auto-update', - 'agent', - '--no-leader', - 'stdio' - ]) - expect(GROK.args({ fullAccess: true })).toEqual([ - '--no-auto-update', - 'agent', - '--no-leader', - '--always-approve', - 'stdio' - ]) - expect(GROK.env).toEqual({ GROK_DISABLE_AUTOUPDATER: '1' }) + it('runs `grok agent stdio`, asking for always-approve only with full access', () => { + expect(GROK.args({ fullAccess: false })).toEqual(['agent', 'stdio']) + expect(GROK.args({ fullAccess: true })).toEqual(['agent', '--always-approve', 'stdio']) + expect(GROK.env).toEqual({}) }) }) From aa803929d2d2be951cece43b0689e5b582d7777c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:06:51 -0700 Subject: [PATCH 211/269] fix(acp): an agent that ends its stdout, or answers unreadably, is not a lost connection As in the common pattern, only a broken stdin (or Orca's own close) ends the agent; one that closed its output but can still be written to stays until a Stop, a close or its exit. The provider supervisor goes back to its base content, so Claude and Codex no longer get the forwarded stdout end either. --- src/main/acp/acp-structured-acquire.ts | 15 ++++--- .../acp/acp-structured-broken-stream.test.ts | 30 +++++++------ .../acp/acp-structured-host-crash.test.ts | 4 +- .../acp/acp-structured-host-lifecycle.test.ts | 3 +- ...ructured-session-adapter-lifecycle.test.ts | 43 ++++++++++++++----- src/main/acp/acp-structured-turns.ts | 6 +-- ...der-process-supervisor.integration.test.ts | 21 --------- .../provider-process-supervisor.ts | 3 -- 8 files changed, 63 insertions(+), 62 deletions(-) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 6dd839ea6cd7..505faa329684 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -59,7 +59,7 @@ export async function acquireAcpStructuredSession(input: { track: (child: AcpStructuredChild) => void /** The child's exit, observed while or after the session exists. */ onExit: (session: AcpStructuredSession | null) => void - /** The connection closed with the child perhaps still running. Null while starting: then the + /** The connection broke with the child perhaps still running. Null while starting: then the * start itself fails. */ onConnectionLost: (session: AcpStructuredSession | null, error: Error) => void onSettled: AcpStructuredTurnsDeps['settle'] @@ -150,7 +150,13 @@ export async function acquireAcpStructuredSession(input: { }), onClose: (error) => { connection.closed = true - input.onConnectionLost(session, error) + // An agent that ended its stdout but can still be written to may still run: Stop or its exit + // ends it. Any other close (a broken stdin, or Orca's own) loses the agent. + const outputOnlyEnded = + child.stdout.readableEnded && child.stdin.writable && !child.stdin.destroyed + if (!outputOnlyEnded) { + input.onConnectionLost(session, error) + } } }) // An abort fails whatever the agent left unanswered at once, as a kill does, whether or not the @@ -273,10 +279,7 @@ export async function acquireAcpStructuredSession(input: { lane: liveLane, agentName, now, - settle: input.onSettled, - // An answer Orca cannot read leaves the connection untrustworthy, as a broken one is. - onTransportFault: (error) => - runtime.close(error instanceof Error ? error : new Error(String(error))) + settle: input.onSettled }), restoreSkipped, closeRequested: false, diff --git a/src/main/acp/acp-structured-broken-stream.test.ts b/src/main/acp/acp-structured-broken-stream.test.ts index 7ccc06a76efd..26e45854d9e9 100644 --- a/src/main/acp/acp-structured-broken-stream.test.ts +++ b/src/main/acp/acp-structured-broken-stream.test.ts @@ -1,12 +1,16 @@ // A real child (plain Node, never an agent CLI) that stops talking mid-turn by closing its stdout -// while the process itself keeps running: the connection broke, nothing exited. +// while the process itself keeps running: nothing exited and Orca can still write to it, so, as in +// the common pattern, the turn runs on until a Stop or close ends the agent. import { mkdtempSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' -import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { GROK_CONFIG_OPTIONS, openAcpAdapterRig, @@ -58,7 +62,7 @@ function alive(pid: number): boolean { } describe('ACP agent that closes its stdout but keeps running', () => { - it('reads the closed stream as a broken connection: turn unverifiable, child stopped, ended once', async () => { + it('leaves the turn running and the agent alive until Orca closes it', async () => { const pidFile = join(mkdtempSync(join(tmpdir(), 'orca-acp-broken-stream-')), 'pid') const rig = await openAcpAdapterRig({ deps: { @@ -85,17 +89,17 @@ describe('ACP agent that closes its stdout but keeps running', () => { const turns = async () => (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) await vi.waitFor( - async () => expect((await turns()).at(-1)).toMatchObject({ state: 'unverifiable' }), + async () => expect((await turns()).at(-1)).toMatchObject({ state: 'running' }), { timeout: 10_000, interval: 20 } ) - await vi.waitFor( - () => - expect(rig.lifecycle).toMatchObject([ - { type: 'ended', cause: 'unexpected-exit', acquisitionGeneration: 'gen-acp' } - ]), - { timeout: 15_000, interval: 20 } - ) - expect(alive(Number(readFileSync(pidFile, 'utf8')))).toBe(false) - expect((await turns()).some((turn) => turn.state === 'completed')).toBe(false) + const pid = Number(readFileSync(pidFile, 'utf8')) + // Long enough for a closed stream to have reached the adapter. + await new Promise((resolve) => setTimeout(resolve, 500)) + expect((await turns()).at(-1)).toMatchObject({ state: 'running' }) + expect(rig.lifecycle).toEqual([]) + expect(alive(pid)).toBe(true) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(alive(pid)).toBe(false) + expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) }, 30_000) }) diff --git a/src/main/acp/acp-structured-host-crash.test.ts b/src/main/acp/acp-structured-host-crash.test.ts index c2e6ec854040..d0090afc8f82 100644 --- a/src/main/acp/acp-structured-host-crash.test.ts +++ b/src/main/acp/acp-structured-host-crash.test.ts @@ -1,6 +1,6 @@ // A Grok crash mid-reply, through the real host: the host's settlement of the proven exit writes one // `provider-exit:` batch whose cut-short row reports on the turn the crash ended, carrying the -// adapter's failure with Grok's last words. Whether Grok's stdout closed before its exit was seen +// adapter's failure with Grok's last words. Whether Grok's pipes closed before its exit was seen // changes only what the chat shows in between. import { afterEach, describe, expect, it, vi } from 'vitest' @@ -85,7 +85,7 @@ describe('a Grok crash with its exit seen first', () => { }) }) -describe('a Grok crash whose stdout closes before its exit is seen (every POSIX crash)', () => { +describe('a Grok crash whose pipes close before its exit is seen', () => { it('is unverifiable only until the exit is proven; the provider-exit batch then revises the turn', async () => { const rig = await midReply() // The child outlives its stream until Orca's close of it is proven. diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index 159be2f820ae..a1fd2dbd342c 100644 --- a/src/main/acp/acp-structured-host-lifecycle.test.ts +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -132,7 +132,8 @@ describe('a Grok crash whose exit is not proven yet', () => { return proves } child.stderr = 'panic: out of memory' - child.stdout.end() + // Both pipes close as the process dies, before its exit is seen. + child.agent.close() await rig.settle() const next = await send(host, 'next') await waitFor(async () => diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index b58f825d76de..d210ad6c23a1 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -157,13 +157,13 @@ describe('ACP Stop of a turn Grok began itself', () => { }) describe('ACP connection loss', () => { - it('settles a send the agent never took as unknown, stops the child and tells the host', async () => { + it('settles a send the agent never took as unknown when its stdin breaks, stops the child and tells the host', async () => { const rig = await openAcpAdapterRig() await rig.acquire() await sendHello(rig, 'lost') await rig.frame('session/prompt') await sendHello(rig, 'held') - rig.child().stdout.end() + rig.child().stdin.destroy() await rig.settle() await waitFor(() => expect(rig.lifecycle).toMatchObject([ @@ -182,11 +182,14 @@ describe('ACP connection loss', () => { await expect(sendHello(rig, 'after')).rejects.toThrow(/no live grok child/) }) - it("ends a crash whose stdout closed before its exit with Grok's last words", async () => { + it("ends a crash whose stdout closed before its exit at that exit, with Grok's last words", async () => { const rig = await openAcpAdapterRig() await rig.acquire() rig.child().stderr = 'panic: out of memory' rig.child().stdout.end() + await rig.settle() + expect(rig.lifecycle).toEqual([]) + rig.child().exit() await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) expect(rig.lifecycle[0]).toMatchObject({ type: 'ended', @@ -205,7 +208,7 @@ describe('ACP connection loss', () => { return false } child.stderr = 'panic: late' - child.stdout.end() + child.agent.close() await rig.settle() expect(rig.lifecycle).toEqual([]) // Never left Orca, so it is not recorded as unconfirmed. @@ -234,31 +237,49 @@ describe('ACP connection loss', () => { }) }) - it('leaves a running turn unverifiable, never completed, when the stream breaks mid-turn', async () => { + it('leaves a running turn unverifiable, never completed, when its stdin breaks mid-turn', async () => { const rig = await openAcpAdapterRig() await rig.acquire() await sendHello(rig, 'lost') await rig.frame('session/prompt') rig.child().agent.notify('session/update', replyChunk('prompt:lost', 'partial')) await rig.settle() - rig.child().stdout.end() + rig.child().stdin.destroy() await waitFor(async () => expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'unverifiable' }) ) expect(rig.child().closes).toBe(1) }) - it('treats an answer Orca cannot read as a broken connection, not a finished turn', async () => { + it('leaves the turn running on an answer Orca cannot read, until a Stop ends Grok', async () => { const rig = await openAcpAdapterRig() await rig.acquire() await sendHello(rig, 'garbled') const prompt = await rig.frame('session/prompt') rig.child().agent.notify('session/update', replyChunk('prompt:garbled', 'partial')) rig.child().agent.reply(prompt, { stopReason: 42 }) - await waitFor(async () => - expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'unverifiable' }) - ) - expect(rig.child().closes).toBe(1) + await rig.settle() + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'running' }) + expect(rig.child().closes).toBe(0) + expect(rig.lifecycle).toEqual([]) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(rig.child().exited).toBe(true) + }) + + it('leaves Grok running when it ends its stdout but can still be written to', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'quiet') + await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:quiet', 'partial')) + await rig.settle() + rig.child().stdout.end() + await rig.settle() + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'running' }) + expect(rig.child().closes).toBe(0) + expect(rig.lifecycle).toEqual([]) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) }) }) diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index 72aee2f2c1ee..eb39b3fa2963 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -52,9 +52,6 @@ export type AcpStructuredTurnsDeps = { agentName: string now: () => number settle: (settlement: AcpDispatchSettlement) => void - /** The prompt failed without an answer from the agent: the connection is no longer trustworthy, - * and the session's end settles the send. */ - onTransportFault: (error: unknown) => void } export class AcpStructuredTurns { @@ -148,8 +145,7 @@ export class AcpStructuredTurns { } if (!(error instanceof AcpAgentError)) { // No answer from the agent, so neither a refusal nor an end: the send stays running here - // until the session's end settles it `unknown`. - this.deps.onTransportFault(error) + // until a Stop or the session's end settles it. return } const refusal = lane.translator.promptRefused(send.clientMessageId, error) diff --git a/src/main/provider-process/provider-process-supervisor.integration.test.ts b/src/main/provider-process/provider-process-supervisor.integration.test.ts index 034d72949805..b3b7f068b7d3 100644 --- a/src/main/provider-process/provider-process-supervisor.integration.test.ts +++ b/src/main/provider-process/provider-process-supervisor.integration.test.ts @@ -281,27 +281,6 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe expect(alive(pids.grandchild)).toBe(false) }) - it('ends its own stdout when the provider closes stdout but keeps running', async () => { - const { supervisor } = launchSupervisor( - {}, - {}, - { - command: process.execPath, - args: [ - '-e', - "process.stdout.write(JSON.stringify({ provider: process.pid }) + '\\n', () => require('node:fs').closeSync(1)); setInterval(() => {}, 60000)" - ] - } - ) - const ended = new Promise((resolve) => supervisor.stdout!.once('end', resolve)) - const { provider } = await readPids(supervisor, ['provider']) - supervisor.stdout!.resume() - - await ended - expect(alive(provider)).toBe(true) - expect(alive(supervisor.pid!)).toBe(true) - }) - it('closes a provider that exits on stdin end without waiting out any grace', async () => { const { supervisor, exit } = launchSupervisor( {}, diff --git a/src/main/provider-process/provider-process-supervisor.ts b/src/main/provider-process/provider-process-supervisor.ts index c7281dbb51a9..ac47a4a51120 100644 --- a/src/main/provider-process/provider-process-supervisor.ts +++ b/src/main/provider-process/provider-process-supervisor.ts @@ -96,9 +96,6 @@ process.stdin.once('end', scheduleOwnerShutdown) process.stdin.once('close', scheduleOwnerShutdown) process.stdin.pipe(child.stdin) child.stdout.pipe(process.stdout) -// pipe() never ends process.stdout: forward the provider's closed stdout so the owner reads EOF. -child.stdout.once('end', () => process.stdout.end()) -child.stdout.once('error', () => process.stdout.end()) child.stderr.pipe(process.stderr) // A dead owner's stdout pipe raises EPIPE; unhandled, it would end this pid before the group. for (const stream of [process.stdin, process.stdout, process.stderr, child.stdin, child.stdout, child.stderr]) { From fa8e96972acd842652f2d0b46bfc8576d4789bda Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:16:08 -0700 Subject: [PATCH 212/269] fix(native-chat): a person's close joining a failed one still binds the turn its child end cuts On main every close of the chat writes its own Stop and settle. Here a later close joins the first and writes no row, and the first's settle closed when its kill failed, so a turn that opened in between and was cut by the next close read as failed. A person's close joining a person's close whose Stop opened a settle now reopens that settle until its attempt is done. Tests: a close whose kill failed still closes its settle; a turn opened between a failed close and the next reads as the person's cancellation (each fails without its half of the fix). --- .../structured-agent-session-host-lifetime.ts | 11 +++- .../structured-agent-session-host-types.ts | 5 +- ...-agent-session-stop-settle-binding.test.ts | 56 +++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) 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 1107272bfc4d..1f0d6c65e68a 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 @@ -107,6 +107,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( return } const cause = 'recorded' in ending ? ending.recorded : ending.cause + const joining = child.close !== undefined if (!child.close) { // Judged before the kill: a stop that ends nothing writes nothing. Its event is issued before // the kill and never awaited by it; the journal writes rows in order. @@ -123,6 +124,13 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( // The same stop asked again, such as a second close of the chat, closes what came since. child.close.requestedAt = session.journal.cursor() } + const close = child.close + // A person's close joining one whose Stop opened a settle reopens it until this attempt is done, + // as a fresh close's would: a turn its child's end cuts is still theirs. + const rejoined = + joining && close.cause === 'user-close' && cause === 'user-close' && (await close.recorded) + ? session.journal.stopMarks.beginSettle() + : null try { if (context.restartWitness) { await snapshotBeforeStructuredAgentSessionStop( @@ -143,7 +151,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( } } finally { // A person's close binds what its child's end cut; done, proven or not, it binds no more. - void child.close?.recorded.then((settle) => session.journal.stopMarks.settled(settle)) + void close.recorded.then((settle) => session.journal.stopMarks.settled(settle)) + session.journal.stopMarks.settled(rejoined) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 167dc50f774d..fbba9d3b3da9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -50,9 +50,8 @@ export type StructuredAgentSessionChildClose = { /** The first stop's, which the child's end keeps however many asks join it. */ readonly cause: StructuredAgentSessionStopCause readonly reason: string | null - /** The Stop event that stop wrote, folded before the work it ends is settled. */ - /** Resolves to the settle a person's close opened, which the stop closes once the child's end is - * done. */ + /** The Stop event that stop wrote, folded before the work it ends is settled. Resolves to the + * settle a person's close opened, which the stop closes once the child's end is done. */ readonly recorded: Promise /** Where the journal stood when that stop was asked for: the child's end is ordered there, so a * message accepted while the exit was being proven came after it. A repeated ask moves it. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts index 14e62854bbcc..8c4dc55a3b42 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts @@ -194,4 +194,60 @@ describe("a person's close pressed before its send's turn showed", () => { expect(later).toMatchObject({ state: 'interrupted' }) expect(later).not.toHaveProperty('outcome') }) + + /** Turn `turn-later` starts and ends on its own, interrupted, after the close returned. */ + async function laterTurnEndsOnItsOwn() { + await journal().appendItem( + LATER, + { kind: 'turn', turnId: 'turn-later', state: 'running', startedAt: Date.now() }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal().appendItem( + LATER, + { + kind: 'turn', + turnId: 'turn-later', + state: 'interrupted', + startedAt: Date.now(), + completedAt: Date.now() + 5 + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return journal() + .snapshot() + .items.map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-later') + } + + // The close fails, but it is over: its settle closes with it. + it('binds no turn that ends on its own after a close whose kill failed', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + await expect( + rig.host['lifetime'].stopAgent(HOST_TEST_SESSION, { cause: 'user-close' }) + ).rejects.toThrow() + expect(journal().stopMarks.latest()?.event).toMatchObject({ reason: 'user-close' }) + + const later = await laterTurnEndsOnItsOwn() + expect(later).toMatchObject({ state: 'interrupted' }) + expect(later).not.toHaveProperty('outcome') + }) + + // A second close joins the first, whose kill failed; the turn the send opened meanwhile is the + // person's, as it would be had the second close written its own Stop. + it('reads a turn that opened between a failed close and the next as interrupted by the person', async () => { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + await expect( + rig.host['lifetime'].stopAgent(HOST_TEST_SESSION, { cause: 'user-close' }) + ).rejects.toThrow() + await turnOpens(sent) + + await rig.host['lifetime'].stopAgent(HOST_TEST_SESSION, { cause: 'user-close' }) + + expect(openedTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(nothingRuns()).toBe(true) + }) }) From a08bbef2c1b2d767dfc5fbd748ffda69640b8a9c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:22:00 -0700 Subject: [PATCH 213/269] test(native-chat): Grok opens as a chat only behind the structured-chat setting agent.launch and orchestration worker-start read the same setting as the renderer route; pin both states for Grok on each. The setting's description no longer names only Codex and Claude, in every catalog. --- .../agent-launch-caller-renders.test.ts | 32 ++++++++++++++++--- ...tion-worker-start-registered-agent.test.ts | 30 +++++++++++++++++ .../settings/ExperimentalPane.test.tsx | 2 +- .../NativeChatExperimentalSetting.tsx | 2 +- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- 10 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts b/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts index 5925bbdc450c..9ad8705529b6 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts @@ -2,7 +2,8 @@ * `agent.launch` makes a chat only for a caller that can show it. `agent.launch.v2` vouches for * Claude and Codex chats; any other agent's chat needs the client to read it, by the rule tabs and * restart offers use, else it gets a terminal. The host's own callers (CLI, orchestration over the - * runtime socket) carry no capability list and are unaffected. + * runtime socket) carry no capability list and are unaffected. With the structured-chat setting + * off, every agent, Grok included, opens as a terminal. */ import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -11,7 +12,13 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { RpcContext } from '../core' -import { CAPABLE_CLIENT, methodNamed, rpcContext, runtimeStub } from './agent-launch.test-fixture' +import { + CAPABLE_CLIENT, + methodNamed, + rpcContext, + runtimeStub, + STRUCTURED_PREFERENCE +} from './agent-launch.test-fixture' const createStructuredSession = vi.fn(async (_args: Record) => ({ ok: true as const, @@ -26,8 +33,12 @@ vi.mock('./structured-agent-session-create', () => ({ const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') -async function launchInto(agent: string, context: Partial) { - const runtime = runtimeStub() +async function launchInto( + agent: string, + context: Partial, + settings?: Record +) { + const runtime = runtimeStub(settings ? { settings } : {}) const parsed = AGENT_LAUNCH.params.safeParse({ agent, target: { kind: 'existing', worktree: 'id:wt-7' } @@ -77,3 +88,16 @@ describe('a launch the caller cannot show as a chat', () => { expect(result.outcome).toMatchObject({ kind: 'structured', sessionId: 'sess-1' }) }) }) + +describe('Grok with the structured-chat setting off', () => { + it('opens as a terminal for every caller, as Claude and Codex do', async () => { + const off = { ...STRUCTURED_PREFERENCE, experimentalStructuredNativeChat: false } + for (const agent of ['grok', 'claude']) { + const { result, runtime } = await launchInto(agent, {}, off) + expect(result.outcome).toMatchObject({ kind: 'terminal' }) + expect(result.receipt).toMatchObject({ mode: 'terminal', reason: 'user_default' }) + expect(runtime.createTerminal).toHaveBeenCalledTimes(1) + } + expect(createStructuredSession).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts new file mode 100644 index 000000000000..97e870b26c41 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts @@ -0,0 +1,30 @@ +// An orchestration worker for a registered agent (Grok) follows the same structured-chat setting as +// Claude and Codex: a chat when the user's default is a structured chat, a terminal when it is off. + +import { describe, expect, it } from 'vitest' +import { decideWorkerStartMode } from './orchestration-worker-start-mode' + +const STRUCTURED_PREFERENCE = { + experimentalNativeChat: true, + experimentalStructuredNativeChat: true, + openAgentTabsInChatByDefault: true +} as const + +describe('a Grok worker and the structured-chat setting', () => { + it('starts as a structured chat while the setting is on', () => { + expect( + decideWorkerStartMode({ params: { agent: 'grok' }, settings: STRUCTURED_PREFERENCE }) + ).toMatchObject({ mode: 'structured', reason: 'user_default' }) + }) + + it('starts as a terminal agent while the setting is off, as Claude does', () => { + for (const agent of ['grok', 'claude']) { + expect( + decideWorkerStartMode({ + params: { agent }, + settings: { ...STRUCTURED_PREFERENCE, experimentalStructuredNativeChat: false } + }) + ).toMatchObject({ mode: 'terminal', preferred: 'terminal', reason: 'user_default' }) + } + }) +}) diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index 6513e9de22b7..9d93849b5ecd 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -260,7 +260,7 @@ describe('ExperimentalPane', () => { expect(container.textContent).toContain('Use updated structured native chat') // The one setting governs both providers, so its copy must not name only Codex. - expect(container.textContent).toContain('Open new Codex and Claude agents as structured chats.') + expect(container.textContent).toContain('Open new agents as structured chats where supported.') // The setting picks what new agents open as; existing chats are left alone. expect(container.textContent).toContain('Chats that already exist stay as they are.') // Paired Orca servers run structured chats too; only WSL and SSH stay on terminal chat. diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 6c85a178965e..89a6a6d0080a 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -135,7 +135,7 @@ export function NativeChatExperimentalSetting({

{translate( 'auto.components.settings.ExperimentalPane.nativeChat.structuredCopy', - 'Open new Codex and Claude agents as structured chats. Off opens them in the terminal-backed chat. Chats that already exist stay as they are.' + 'Open new agents as structured chats where supported. Off opens them in the terminal-backed chat. Chats that already exist stay as they are.' )}

diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 18ba17822e79..5c1490954c2e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7352,7 +7352,7 @@ "defaultViewTerminal": "Terminal chat", "defaultViewNative": "Chat UI", "structuredTitle": "Use updated structured native chat", - "structuredCopy": "Open new Codex and Claude agents as structured chats. Off opens them in the terminal-backed chat. Chats that already exist stay as they are.", + "structuredCopy": "Open new agents as structured chats where supported. Off opens them in the terminal-backed chat. Chats that already exist stay as they are.", "structuredScope": "Runs on this machine and on paired Orca servers running a version that supports it; older servers keep terminal chat. WSL and SSH hosts continue to use terminal chat, and Windows falls back to it unless Orca can read process start times.", "structuredToggleLabel": "Toggle updated structured native chat", "resumeTitle": "Resume working chats automatically after a restart", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 257a7cafb3ac..266cb12a0026 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -7139,7 +7139,7 @@ "shellEnvToggleLabel": "Alternar el uso del entorno de tu shell", "resumeCopy": "Cuando Orca se cierra o instala una actualización, los chats que estaban trabajando se reanudan automáticamente al volver a abrir Orca.", "structuredTitle": "Utilice el chat nativo estructurado actualizado", - "structuredCopy": "Abra nuevos agentes de Codex y Claude como chats estructurados. Off los abre en el chat respaldado por la terminal. Los chats que ya existen permanecen como están.", + "structuredCopy": "Abra nuevos agentes como chats estructurados cuando sea posible. Off los abre en el chat respaldado por la terminal. Los chats que ya existen permanecen como están.", "structuredScope": "Se ejecuta en esta máquina y en servidores Orca emparejados que ejecutan una versión que lo admita; Los servidores más antiguos mantienen el chat del terminal. Los hosts WSL y SSH continúan usando el chat de terminal y Windows recurre a él a menos que Orca pueda leer las horas de inicio del proceso.", "structuredToggleLabel": "Alternar chat nativo estructurado actualizado", "resumeTitle": "Reanudar los chats de trabajo automáticamente después de reiniciar", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 5caed9b4ffc5..6a76b7e4d7b1 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -7241,7 +7241,7 @@ "shellEnvCopy": "Les chats Codex et Claude démarrent avec toutes les variables exportées par votre shell de connexion, comme un terminal. Désactivez pour choisir celles qu'ils reçoivent.", "shellEnvToggleLabel": "Activer l'utilisation de l'environnement de votre shell", "structuredTitle": "Utiliser le chat natif structuré mis à jour", - "structuredCopy": "Ouvre les nouveaux agents Codex et Claude sous forme de discussions structurées. Désactivé, ils s'ouvrent dans le chat basé sur le terminal. Les discussions existantes restent telles quelles.", + "structuredCopy": "Ouvre les nouveaux agents sous forme de discussions structurées lorsque c'est possible. Désactivé, ils s'ouvrent dans le chat basé sur le terminal. Les discussions existantes restent telles quelles.", "structuredScope": "Fonctionne sur cette machine et sur les serveurs Orca appairés dont la version le prend en charge ; les serveurs plus anciens conservent le chat du terminal. WSL et les hôtes SSH continuent d'utiliser le chat du terminal, et Windows y revient à moins qu'Orca ne puisse lire les heures de début des processus.", "structuredToggleLabel": "Basculer le chat natif structuré mis à jour", "resumeTitle": "Reprendre automatiquement les discussions de travail après un redémarrage", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 49e78a21a046..5d9c987570fd 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -7123,7 +7123,7 @@ "shellEnvCopy": "Codex と Claude のチャットは、ターミナルと同じく、ログインシェルがエクスポートするすべての変数を引き継いで開始します。オフにすると、渡す変数を選択できます。", "shellEnvToggleLabel": "シェル環境の使用の切り替え", "structuredTitle": "更新された構造化ネイティブチャットを使用する", - "structuredCopy": "新規の Codex および Claude の Agent を構造化チャットとして開きます。オフにすると、ターミナルを使用したチャットで開きます。既存のチャットはそのまま残ります。", + "structuredCopy": "対応している場合、新規の Agent を構造化チャットとして開きます。オフにすると、ターミナルを使用したチャットで開きます。既存のチャットはそのまま残ります。", "structuredScope": "このマシンと、対応するバージョンを実行しているペアリング済みの Orca サーバーで動作します。古いサーバーはターミナルチャットのままです。WSL および SSH ホストは引き続きターミナルチャットを使用し、Orca がプロセスの開始時間を読み取ることができない限り、Windows はターミナルチャットにフォールバックします。", "structuredToggleLabel": "更新された構造化ネイティブチャットの切り替え", "resumeTitle": "再起動後に作業中のチャットを自動的に再開する", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 82b3f38c1b5d..a43b611779d4 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -7123,7 +7123,7 @@ "shellEnvCopy": "Codex 및 Claude 채팅은 터미널과 마찬가지로 로그인 셸이 내보내는 모든 변수를 가지고 시작합니다. 끄면 전달할 변수를 선택할 수 있습니다.", "shellEnvToggleLabel": "셸 환경 사용 전환", "structuredTitle": "업데이트된 구조화된 기본 채팅 사용", - "structuredCopy": "새 Codex 및 Claude 에이전트를 구조화된 채팅으로 엽니다. 끄면 터미널 지원 채팅으로 엽니다. 이미 있는 채팅은 그대로 유지됩니다.", + "structuredCopy": "지원되는 경우 새 에이전트를 구조화된 채팅으로 엽니다. 끄면 터미널 지원 채팅으로 엽니다. 이미 있는 채팅은 그대로 유지됩니다.", "structuredScope": "이 컴퓨터와 이를 지원하는 버전을 실행하는 페어링된 Orca 서버에서 실행됩니다. 이전 서버는 터미널 채팅을 유지합니다. WSL 및 SSH 호스트는 계속해서 터미널 채팅을 사용하며 Orca가 프로세스 시작 시간을 읽을 수 없으면 Windows는 이 채팅을 사용합니다.", "structuredToggleLabel": "업데이트된 구조화된 기본 채팅 전환", "resumeTitle": "다시 시작한 후 작업 중인 채팅을 자동으로 재개합니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5d43aaa2cf40..1af979e76c08 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -7123,7 +7123,7 @@ "shellEnvCopy": "Codex 和 Claude 聊天启动时会带上登录 shell 导出的所有变量,与终端一致。关闭后可选择传递哪些变量。", "shellEnvToggleLabel": "切换使用 shell 环境", "structuredTitle": "使用更新的结构化本机聊天", - "structuredCopy": "将新的 Codex 和 Claude 代理作为结构化聊天打开。关闭时会在终端支持的聊天中打开。已有的聊天保持不变。", + "structuredCopy": "在支持的情况下,将新的代理作为结构化聊天打开。关闭时会在终端支持的聊天中打开。已有的聊天保持不变。", "structuredScope": "在本机和运行支持该功能版本的已配对 Orca 服务器上运行;较旧的服务器保留终端聊天。WSL 和 SSH 主机继续使用终端聊天,Windows 会回退到它,除非 Orca 可以读取进程启动时间。", "structuredToggleLabel": "切换更新的结构化本机聊天", "resumeTitle": "重启后自动恢复工作聊天", From 557a60248760ffe677a425753fafda737746061b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:23:47 -0700 Subject: [PATCH 214/269] docs(acp): generic ACP comments say what holds for every agent, not Grok Stop ends the session for every ACP agent, as in the common pattern; the adoption hook comment goes (adoption is not planned); a failed start's child is retried at the next start or quit. --- src/main/acp/acp-structured-acquire.ts | 2 -- src/main/acp/acp-structured-session-adapter.ts | 9 +++++---- src/main/acp/acp-structured-starts.ts | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 505faa329684..ff77c4facfa8 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -223,8 +223,6 @@ export async function acquireAcpStructuredSession(input: { let liveLane: AcpStructuredLane | null = null let supersedesKey: string | undefined if (resume) { - // Adoption would plug in here: a chat whose journal holds none of the agent's history would - // load with the translator adopting the replay. No Grok chat reaches that state today. const attaching = makeLane(resume.sessionId, true) liveLane = attaching try { diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index d068570266fd..009b36df044b 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -160,14 +160,15 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte ? { cancelled: true } : { cancelled: false, refusal: { turnNotRunning: true } } } - // Answers every open request cancelled and lets Grok end its turn its own way; the host ends - // the child once that lands or the grace runs out (`awaitStoppedRequestEnd`). + // Answers every open request cancelled and lets the agent end its turn its own way; the host + // ends the child once that lands or the grace runs out (`awaitStoppedRequestEnd`). void session.runtime.cancel().catch(() => undefined) return { cancelled: true } } - // Stop is a session boundary: Grok's cancel ends only the running turn, and work it already moved - // to the background runs on and can begin a turn of its own. The next send resumes the session. + // Stop is a session boundary for every ACP agent, as in the common pattern: a cancel ends only + // the running turn, and work the agent moved to the background could begin a turn of its own. + // The next send reloads the session. stopEndsSession = (): boolean => true awaitStoppedRequestEnd = async (sessionId: string, stoppedAt: number): Promise => { diff --git a/src/main/acp/acp-structured-starts.ts b/src/main/acp/acp-structured-starts.ts index b06578eacd42..26005dc7cc92 100644 --- a/src/main/acp/acp-structured-starts.ts +++ b/src/main/acp/acp-structured-starts.ts @@ -1,6 +1,6 @@ // The adapter's starts: each acquire under way, stopped when the host aborts its signal (a close, // a Stop that must not wait behind it, or quit), and each failed start's child until its exit is -// proven, so a later close retries it instead of answering for a child it no longer knows. +// proven, so the next start or quit retries it instead of answering for a child it no longer knows. import type { AcpStructuredChild } from './acp-structured-child' From f5b6658b3b2b0bb536d6127aa2753531f23a1902 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:29:59 -0700 Subject: [PATCH 215/269] test(claude, codex): type the EOF-before-exit test's streams; the supervisor no longer forwards EOF The Claude test wrote to the child's stdout and stderr through their Readable type, which the node typecheck rejects; it now holds its own PassThrough streams. The comments no longer credit the reverted supervisor change. --- ...laude-stream-json-connection-close.test.ts | 21 ++++++++++++------- .../codex/codex-app-server-connection.test.ts | 2 +- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/main/claude/claude-stream-json-connection-close.test.ts b/src/main/claude/claude-stream-json-connection-close.test.ts index 62c284b1d67e..7c4c701a273e 100644 --- a/src/main/claude/claude-stream-json-connection-close.test.ts +++ b/src/main/claude/claude-stream-json-connection-close.test.ts @@ -26,13 +26,18 @@ vi.mock('./claude-agent-sdk-exit-proof', () => ({ proveClaudeChildExit: (...args: unknown[]) => mocks.proveClaudeChildExit(...args) })) -function fakeChild(): ChildProcessWithoutNullStreams { +function fakeChild( + streams: { stdout: PassThrough; stderr: PassThrough } = { + stdout: new PassThrough(), + stderr: new PassThrough() + } +): ChildProcessWithoutNullStreams { const child = new EventEmitter() return Object.assign(child, { pid: 424242, stdin: new PassThrough(), - stdout: new PassThrough(), - stderr: new PassThrough(), + stdout: streams.stdout, + stderr: streams.stderr, kill: vi.fn() }) as unknown as ChildProcessWithoutNullStreams } @@ -133,13 +138,15 @@ describe('Claude stream-json close ordering', () => { await expect(connection.close()).resolves.toBe(true) }) - // The provider supervisor ends Orca's stdout when Claude's ends, so EOF now comes before the exit. + // Stdout can end before the exit is seen, as when the provider supervisor's own exit closes it. it('reports an exit whose stdout ended first once the exit is seen, with its usual reason', async () => { mocks.refresh.mockReset() mocks.proveClaudeChildExit.mockReset() mocks.refresh.mockResolvedValue(undefined) mocks.proveClaudeChildExit.mockResolvedValue(true) - const child = fakeChild() + const stdout = new PassThrough() + const stderr = new PassThrough() + const child = fakeChild({ stdout, stderr }) // The SDK's stream ends with the child's stdout. const next = vi .fn<() => Promise>>>() @@ -164,8 +171,8 @@ describe('Claude stream-json close ordering', () => { queryImpl ) - child.stderr.write('session limit reached\n') - child.stdout.end() + stderr.write('session limit reached\n') + stdout.end() await vi.waitFor(() => expect(next).toHaveBeenCalledOnce()) await new Promise((resolve) => setImmediate(resolve)) expect(exits).toEqual([]) diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index a154ab15c76d..b5c47620cc96 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -780,7 +780,7 @@ describe('openCodexAppServerConnection', () => { await connection.close() }) - // The provider supervisor ends Orca's stdout when Codex's ends, so EOF now comes before the exit. + // Stdout can end before the exit is seen, as when the provider supervisor's own exit closes it. it('reports an exit whose stdout ended first once the exit is seen, with its usual reason', async () => { const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) answerInitialize(child) From 76a91abf6c2c43be69160416790e8e73401a4293 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:33:53 -0700 Subject: [PATCH 216/269] feat(acp): a steer's cancel asks once and never ends the agent The runtime had one cancel: send session/cancel, wait at most 10 s for Orca's prompt to settle, then close the connection, which ends the agent. A steer used it too, so a slow agent lost its process just because the person added a message. requestSteerCancel() now sends session/cancel once per prompt, cancels the agent's open requests and answers later permissions cancelled, and never bounds or closes: the prompt's own reply ends it and the steer's prompt follows. cancel() stays the Stop: bounded, then close. A Stop after a steer still bounds and closes. Both cancel paths move into acp-prompt-cancel.ts over one cancel channel. --- src/main/acp/acp-prompt-cancel.ts | 54 ++++++++++++++++++++--- src/main/acp/acp-session-runtime.test.ts | 56 ++++++++++++++++++++++++ src/main/acp/acp-session-runtime.ts | 54 +++++++++++++---------- 3 files changed, 134 insertions(+), 30 deletions(-) diff --git a/src/main/acp/acp-prompt-cancel.ts b/src/main/acp/acp-prompt-cancel.ts index 5208c6df1465..206b1b3fa77e 100644 --- a/src/main/acp/acp-prompt-cancel.ts +++ b/src/main/acp/acp-prompt-cancel.ts @@ -7,15 +7,36 @@ export type ActivePrompt = { cancelPromise?: Promise } +/** How a cancel reaches the agent: the `session/cancel` write and the connection it rides on. */ +export type AcpCancelChannel = { + send: () => Promise + /** Cancels the agent's open requests (permissions, vendor hooks) so each gets its answer. */ + cancelIncomingRequests: () => void + close: (error: Error) => void + closed: () => boolean + timeoutMs: number +} + +/** A Stop's cancel. Sends `session/cancel` whenever the session runs: the agent may be in a turn it + * began itself. With Orca's prompt running, also waits (bounded) for that prompt to settle, and + * past the bound closes the connection, which ends the agent. Repeated asks share one wait. */ +export function cancelAcpPromptForStop( + active: ActivePrompt | undefined, + channel: AcpCancelChannel +): Promise { + channel.cancelIncomingRequests() + if (!active) { + return channel.send() + } + active.cancelling = true + active.cancelPromise ??= confirmAcpPromptCancel(active, channel) + return active.cancelPromise +} + /** Sends `session/cancel` and waits, bounded, for Orca's prompt to settle; past the bound it closes. */ -export async function confirmAcpPromptCancel( +async function confirmAcpPromptCancel( active: ActivePrompt, - connection: { - send: () => Promise - close: (error: Error) => void - closed: () => boolean - timeoutMs: number - } + connection: AcpCancelChannel ): Promise { let timer: ReturnType | undefined const unconfirmed = new Promise((_resolve, reject) => { @@ -44,3 +65,22 @@ export async function confirmAcpPromptCancel( clearTimeout(timer) } } + +/** A steer's cancel: asks the agent once to end Orca's running prompt so the steer can follow it, + * and resolves when the notification is written. Never bounded and never closes: the prompt's own + * reply ends it, and a later Stop still bounds. With no prompt of Orca's there is nothing to end. */ +export function requestAcpSteerCancel( + active: ActivePrompt | undefined, + channel: Pick +): Promise { + if (!active || active.cancelling) { + return Promise.resolve() + } + active.cancelling = true + channel.cancelIncomingRequests() + return channel.send().catch((error: unknown) => { + // Only a cancel that reached the agent counts; a failed write may be asked again. + active.cancelling = false + throw error + }) +} diff --git a/src/main/acp/acp-session-runtime.test.ts b/src/main/acp/acp-session-runtime.test.ts index b89e0b58059f..210fbe09fe69 100644 --- a/src/main/acp/acp-session-runtime.test.ts +++ b/src/main/acp/acp-session-runtime.test.ts @@ -421,4 +421,60 @@ describe('ACP session runtime', () => { await Promise.all([rejectedPrompt, cancelled]) await expect(runtime.prompt([...textPrompt])).rejects.toBeInstanceOf(AcpRequestTimeoutError) }) + + describe("a steer's cancel", () => { + const cancels = (agent: AcpScriptedAgent) => + agent.frames.filter((frame) => frame.method === 'session/cancel') + + it("asks once, never times out or closes, and the prompt's own reply ends it", async () => { + vi.useFakeTimers() + const { runtime, agent } = fixture({}, { cancelTimeoutMs: 100 }) + const promptFrame = deferred[0]>() + agent.on('session/prompt', (frame) => promptFrame.resolve(frame)) + await runtime.start(startOptions) + const prompt = runtime.prompt([...textPrompt]) + const frame = await promptFrame.promise + + await runtime.requestSteerCancel() + await runtime.requestSteerCancel() + await vi.advanceTimersByTimeAsync(1_000) + + expect(cancels(agent)).toHaveLength(1) + // The agent's late permission is answered cancelled while its prompt winds down. + expect(await agent.request('late', 'session/request_permission', permission)).toMatchObject({ + result: { outcome: { outcome: 'cancelled' } } + }) + agent.reply(frame, { stopReason: 'cancelled' }) + expect(await prompt).toEqual({ stopReason: 'cancelled' }) + // The connection is still open, so the steer's own prompt follows. + agent.on('session/prompt', (next) => agent.reply(next, { stopReason: 'end_turn' })) + expect(await runtime.prompt([...textPrompt])).toEqual({ stopReason: 'end_turn' }) + }) + + it('leaves a later Stop bounded, closing the connection when the prompt never settles', async () => { + vi.useFakeTimers() + const { runtime, agent } = fixture({}, { cancelTimeoutMs: 100 }) + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const prompt = runtime.prompt([...textPrompt]) + const rejectedPrompt = expect(prompt).rejects.toBeInstanceOf(AcpRequestTimeoutError) + + await runtime.requestSteerCancel() + const stopped = expect(runtime.cancel()).rejects.toBeInstanceOf(AcpRequestTimeoutError) + await vi.advanceTimersByTimeAsync(100) + + await Promise.all([rejectedPrompt, stopped]) + expect(cancels(agent)).toHaveLength(2) + await expect(runtime.prompt([...textPrompt])).rejects.toBeInstanceOf(AcpRequestTimeoutError) + }) + + it("writes nothing with no prompt of Orca's running", async () => { + const { runtime, agent } = fixture() + await expect(runtime.requestSteerCancel()).resolves.toBeUndefined() + await runtime.start(startOptions) + await runtime.requestSteerCancel() + await tick() + expect(cancels(agent)).toHaveLength(0) + }) + }) }) diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 1c668fd49bb9..cf10c198586f 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -14,7 +14,12 @@ import { type AcpPermissionHandler } from './acp-permission-requests' import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' -import { confirmAcpPromptCancel, type ActivePrompt } from './acp-prompt-cancel' +import { + cancelAcpPromptForStop, + requestAcpSteerCancel, + type AcpCancelChannel, + type ActivePrompt +} from './acp-prompt-cancel' import { setupAcpSession, type AcpSessionStarted, @@ -173,29 +178,18 @@ export class AcpSessionRuntime { return active.response } - /** Sends `session/cancel` whenever the session runs: the agent may be in a turn it began itself. - * With Orca's prompt running, also waits (bounded) for that prompt to settle. */ + /** A Stop's cancel (`cancelAcpPromptForStop`): bounded, and past the bound it closes. */ cancel(options: { meta?: CancelNotification['_meta'] } = {}): Promise { - if (!this.started) { - return Promise.reject(new Error('ACP session has not started')) - } - const params: CancelNotification = { - sessionId: this.started.sessionId, - ...withMeta(options.meta) - } - this.peer.cancelIncomingRequests() - const active = this.activePrompt - if (!active) { - return this.peer.notify('session/cancel', params) - } - active.cancelling = true - active.cancelPromise ??= confirmAcpPromptCancel(active, { - send: () => this.peer.notify('session/cancel', params), - close: (error) => this.peer.close(error), - closed: () => this.peer.closed, - timeoutMs: Math.min(this.options.cancelTimeoutMs ?? 10_000, MAX_TIMER_DELAY_MS) - }) - return active.cancelPromise + const channel = this.cancelChannel(options.meta) + return channel + ? cancelAcpPromptForStop(this.activePrompt, channel) + : Promise.reject(new Error('ACP session has not started')) + } + + /** A steer's cancel (`requestAcpSteerCancel`): never bounded and never closes. */ + requestSteerCancel(options: { meta?: CancelNotification['_meta'] } = {}): Promise { + const channel = this.cancelChannel(options.meta) + return channel ? requestAcpSteerCancel(this.activePrompt, channel) : Promise.resolve() } async setMode(modeId: string, meta?: Meta): Promise { @@ -240,6 +234,20 @@ export class AcpSessionRuntime { this.listeners.clear() } + private cancelChannel(meta: CancelNotification['_meta']): AcpCancelChannel | null { + if (!this.started) { + return null + } + const params: CancelNotification = { sessionId: this.started.sessionId, ...withMeta(meta) } + return { + send: () => this.peer.notify('session/cancel', params), + cancelIncomingRequests: () => this.peer.cancelIncomingRequests(), + close: (error) => this.peer.close(error), + closed: () => this.peer.closed, + timeoutMs: Math.min(this.options.cancelTimeoutMs ?? 10_000, MAX_TIMER_DELAY_MS) + } + } + private sessionId(): string { if (!this.started) { throw new Error('ACP session has not started') From 2a1cef290f3f47f5b6210c1a77f130edff61740f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:38:57 -0700 Subject: [PATCH 217/269] chore(native-chat): keep the record store and recovery capsule under max-lines after the main merge --- src/main/runtime/agent-session-record-store.ts | 8 +++++--- src/main/runtime/agent-session-recovery-capsule.ts | 4 +--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 68605ed14c89..a9c85c5c7dd8 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -94,10 +94,12 @@ export class AgentSessionRecordStore { agents: AgentSessionStoredAgents }): AgentSessionRecordStore { const loaded = loadAgentSessionStoreRows(args.journalDatabase.db, args.hostId, args.agents) - return new AgentSessionRecordStore( - new AgentSessionStoreTransactions(args.journalDatabase, loaded, args.agents), - args.hostId + const transactions = new AgentSessionStoreTransactions( + args.journalDatabase, + loaded, + args.agents ) + return new AgentSessionRecordStore(transactions, args.hostId) } private get state(): AgentSessionStoreState { diff --git a/src/main/runtime/agent-session-recovery-capsule.ts b/src/main/runtime/agent-session-recovery-capsule.ts index e4b73c25d29c..ba34c82fc57c 100644 --- a/src/main/runtime/agent-session-recovery-capsule.ts +++ b/src/main/runtime/agent-session-recovery-capsule.ts @@ -211,13 +211,11 @@ export class AgentSessionRecoveryCapsule { ): Promise { return withFileTransactionLock(this.filePath, async () => { const named = sessionIds === 'all' ? null : new Set(sessionIds) - const chosen = (marker: AgentSessionResumeMarker) => - (named?.has(marker.sessionId) ?? true) && !keep(marker) const state = await this.readState() const { entries, failed } = normalizeState(state, now) const dismissed = new Set( [...entries, ...failed] - .filter((record) => chosen(record.marker)) + .filter(({ marker }) => (named?.has(marker.sessionId) ?? true) && !keep(marker)) .map((record) => record.marker.sessionId) ) if (dismissed.size > 0) { From 58bb525f8282c89db927e38d5d4d70940b6851d8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:41:22 -0700 Subject: [PATCH 218/269] fix(acp): a repeated steer shares the cancel in flight; say what the caller owns Per review: a second steer before the first write lands returns that write instead of resolving early. The steer's JSDoc says the wait for the prompt's reply is unbounded and that a prompt that fails instead must not take the steer until the caller rebuilds the session; the Stop's says a prompt that settles in time leaves the agent for the Stop's owner to end. The steer test now gives the runtime a handler that would allow: the open permission's signal aborts and the late one never reaches it. --- src/main/acp/acp-prompt-cancel.ts | 14 +++++++++++--- src/main/acp/acp-session-runtime.test.ts | 24 ++++++++++++++++++++---- src/main/acp/acp-session-runtime.ts | 3 ++- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/main/acp/acp-prompt-cancel.ts b/src/main/acp/acp-prompt-cancel.ts index 206b1b3fa77e..8938d10aea3d 100644 --- a/src/main/acp/acp-prompt-cancel.ts +++ b/src/main/acp/acp-prompt-cancel.ts @@ -5,6 +5,7 @@ export type ActivePrompt = { response: Promise cancelling: boolean cancelPromise?: Promise + steerCancel?: Promise } /** How a cancel reaches the agent: the `session/cancel` write and the connection it rides on. */ @@ -68,19 +69,26 @@ async function confirmAcpPromptCancel( /** A steer's cancel: asks the agent once to end Orca's running prompt so the steer can follow it, * and resolves when the notification is written. Never bounded and never closes: the prompt's own - * reply ends it, and a later Stop still bounds. With no prompt of Orca's there is nothing to end. */ + * reply ends it, however long that takes, and a later Stop still bounds. A prompt that fails + * instead leaves a session the steer must not be sent into until the caller rebuilds it. With no + * prompt of Orca's there is nothing to end; a Stop already cancelling it owns that cancel. */ export function requestAcpSteerCancel( active: ActivePrompt | undefined, channel: Pick ): Promise { - if (!active || active.cancelling) { + if (!active) { return Promise.resolve() } + if (active.steerCancel || active.cancelling) { + return active.steerCancel ?? Promise.resolve() + } active.cancelling = true channel.cancelIncomingRequests() - return channel.send().catch((error: unknown) => { + active.steerCancel = channel.send().catch((error: unknown) => { // Only a cancel that reached the agent counts; a failed write may be asked again. active.cancelling = false + active.steerCancel = undefined throw error }) + return active.steerCancel } diff --git a/src/main/acp/acp-session-runtime.test.ts b/src/main/acp/acp-session-runtime.test.ts index 210fbe09fe69..ef5369e8589f 100644 --- a/src/main/acp/acp-session-runtime.test.ts +++ b/src/main/acp/acp-session-runtime.test.ts @@ -428,22 +428,38 @@ describe('ACP session runtime', () => { it("asks once, never times out or closes, and the prompt's own reply ends it", async () => { vi.useFakeTimers() - const { runtime, agent } = fixture({}, { cancelTimeoutMs: 100 }) + const signals: AbortSignal[] = [] + const { runtime, agent } = fixture( + {}, + { + cancelTimeoutMs: 100, + // Would allow every request it is asked; a steer's cancel keeps late ones from it. + onPermission: (_request, context) => { + signals.push(context.signal) + return new Promise(() => {}) + } + } + ) const promptFrame = deferred[0]>() agent.on('session/prompt', (frame) => promptFrame.resolve(frame)) await runtime.start(startOptions) const prompt = runtime.prompt([...textPrompt]) const frame = await promptFrame.promise + const open = agent.request('open', 'session/request_permission', permission) + await vi.waitFor(() => expect(signals).toHaveLength(1)) - await runtime.requestSteerCancel() - await runtime.requestSteerCancel() + const steer = runtime.requestSteerCancel() + expect(runtime.requestSteerCancel()).toBe(steer) + await steer await vi.advanceTimersByTimeAsync(1_000) expect(cancels(agent)).toHaveLength(1) - // The agent's late permission is answered cancelled while its prompt winds down. + expect(signals[0]?.aborted).toBe(true) + expect(await open).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) expect(await agent.request('late', 'session/request_permission', permission)).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect(signals).toHaveLength(1) agent.reply(frame, { stopReason: 'cancelled' }) expect(await prompt).toEqual({ stopReason: 'cancelled' }) // The connection is still open, so the steer's own prompt follows. diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index cf10c198586f..eb6ceb821947 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -178,7 +178,8 @@ export class AcpSessionRuntime { return active.response } - /** A Stop's cancel (`cancelAcpPromptForStop`): bounded, and past the bound it closes. */ + /** A Stop's cancel (`cancelAcpPromptForStop`): bounded, and past the bound it closes. A prompt + * that settles in time leaves the agent running; the Stop's owner ends its process. */ cancel(options: { meta?: CancelNotification['_meta'] } = {}): Promise { const channel = this.cancelChannel(options.meta) return channel From 804c2bb4f6681487e7463e40cdfee92a76d644a6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:16:18 -0700 Subject: [PATCH 219/269] fix(native-chat): drop the stopDelivery the A3 merge doubled --- .../agent-session-wire/structured-agent-session-host.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 90047a22ae18..c59355bc6a91 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -254,8 +254,6 @@ export class StructuredAgentSessionHost { flushStreamedEvents = (sessionId: string): Promise => this.runtimeState.flushEventSink(sessionId) - /** Quit: no exit or recovery settled after this starts a child or hands a message over. */ - stopDelivery = (): void => this.conversationDelivery.dispose() // Trigger inlined rather than imported: `AgentSessionResumeTrigger` in shared is the canonical // type, and this file has no line budget left for the import. /** Quit: no exit or recovery settled after this starts a child or hands a message over. */ From 964db925726ae30509c26e63b303eb6824ef073b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:16:18 -0700 Subject: [PATCH 220/269] fix(acp): a steer's cancel asks Grok once and never ends it A steer now uses D1's notify-only cancel. Two messages sent during a reply Grok began itself cut that reply, as the common pattern does, and then both run; before, the queued first message could not answer the bounded cancel and Orca ended Grok although Grok answered. A Stop keeps the bounded cancel and its 4 s grace. --- src/main/acp/acp-structured-steer.test.ts | 63 ++++++++++++++++------- src/main/acp/acp-structured-turns.ts | 12 ++--- 2 files changed, 51 insertions(+), 24 deletions(-) diff --git a/src/main/acp/acp-structured-steer.test.ts b/src/main/acp/acp-structured-steer.test.ts index 7aa996d9d39a..c9586a12cf48 100644 --- a/src/main/acp/acp-structured-steer.test.ts +++ b/src/main/acp/acp-structured-steer.test.ts @@ -129,9 +129,7 @@ describe('a send while a Grok prompt runs', () => { ) }) - // Unlike the common pattern, which waits on a steer's cancel without a bound: a Grok that ignores - // a cancel that long is hung, and its connection is not trusted further. - it('ends a Grok that never answers a steer cancel, and rejects the steer as never sent', async () => { + it('waits as long as Grok takes to answer a steer cancel, and a Stop then still ends it', async () => { const rig = await openAcpAdapterRig({ deps: { cancelTimeoutMs: 30 } }) await rig.acquire() await sendHello(rig, 'first') @@ -139,25 +137,20 @@ describe('a send while a Grok prompt runs', () => { rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) await rig.settle() await sendHello(rig, 'steer') - await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) - expect(rig.lifecycle[0]).toMatchObject({ - type: 'ended', - cause: 'unexpected-exit', - failure: { kind: 'providerExited' }, - reason: expect.stringContaining('session/cancel') - }) - expect(rig.sent('session/prompt')).toHaveLength(1) + await new Promise((resolve) => setTimeout(resolve, 120)) + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(1) + expect(rig.lifecycle).toEqual([]) + expect(rig.child().closes).toBe(0) + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['first']) + // The Stop's own cancel keeps its bound. + await rig.adapter.cancelTurn({ sessionId: ADAPTER_SESSION, fence: 1 }) + expect(rig.sent('session/cancel')).toHaveLength(2) expect(rig.settled).toEqual( expect.arrayContaining([ - expect.objectContaining({ - clientMessageId: 'steer', - state: 'rejected', - reason: 'Grok stopped before this message was sent.' - }) + expect.objectContaining({ clientMessageId: 'steer', state: 'rejected' }) ]) ) - const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) - expect(turns.map((turn) => turn.state)).toEqual(['unverifiable']) }) }) @@ -184,6 +177,40 @@ describe('a send while a turn Grok began itself runs', () => { const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) expect(turns.map((turn) => turn.state)).toEqual(['completed', 'completed']) }) + + it("cuts Grok's turn on a second send and runs both messages, never ending Grok", async () => { + const rig = await openAcpAdapterRig({ deps: { cancelTimeoutMs: 30 } }) + await rig.acquire() + const { agent } = rig.child() + agent.notify('session/update', replyChunk(GROK_TURN, 'Build finished; now')) + await rig.settle() + await sendHello(rig, 'a') + const a = await rig.frame('session/prompt') + // Grok queues `a` behind its own turn; a cancel ends only the running turn and promotes `a`. + agent.on('session/cancel', () => { + endsGrokTurn(agent) + agent.notify('session/update', replyChunk(promptIdOf(a), 'working on a')) + }) + await sendHello(rig, 'b') + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(1) + // `a` runs a whole turn, well past the Stop bound: a steer's cancel never ends Grok. + await new Promise((resolve) => setTimeout(resolve, 120)) + await rig.settle() + expect(rig.lifecycle).toEqual([]) + expect(rig.child().closes).toBe(0) + agent.reply(a, { stopReason: 'end_turn' }) + const b = await rig.frame('session/prompt', 1) + expect(promptIdOf(b)).toBe('prompt:b') + agent.notify('session/update', replyChunk('prompt:b', 'on b')) + agent.reply(b, { stopReason: 'end_turn' }) + await waitFor(async () => { + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.map((turn) => turn.state)).toEqual(['interrupted', 'completed', 'completed']) + }) + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['a', 'b']) + expect(rig.child().closes).toBe(0) + }) }) describe('Steer on a Grok card, through the host', () => { diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index eb39b3fa2963..af2a879ed19f 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -1,7 +1,7 @@ // The sends of one ACP session. ACP runs one prompt at a time, so a message sent while Orca's prompt // runs steers: that prompt is cancelled (the session stays) and the message goes as the next prompt -// once the agent answers the cancel. A steer waits here until then; one behind it cancels it in turn, -// so the last one runs, and a Stop withdraws what waits. A turn the agent began itself is not Orca's +// once the agent answers the cancel, however long that takes. A steer waits here until then; one +// behind it cancels it in turn, so the last one runs, and a Stop withdraws what waits. A turn the agent began itself is not Orca's // to cut short: a message sent during it goes to the agent at once. Each send is settled exactly once: // accepted when the agent's first event for its turn (or its answer) arrives, rejected when the // agent refused it or it never left Orca, unknown when the agent died with it or its connection @@ -47,7 +47,7 @@ export function acpPromptBlocks(body: AgentJournalMessageItem): ContentBlock[] | type Send = { clientMessageId: string; prompt: ContentBlock[]; requestedAt: number } export type AcpStructuredTurnsDeps = { - runtime: Pick + runtime: Pick lane: AcpStructuredLane agentName: string now: () => number @@ -176,10 +176,10 @@ export class AcpStructuredTurns { } } - /** Answers the agent's open requests cancelled and ends the running prompt. One the agent never - * answers closes the connection past the runtime's bound, which ends the session. */ + /** Answers the agent's open requests cancelled and asks once to end the running prompt; never + * bounded, so a slow answer only delays the steer, and a Stop still ends the session. */ private cancelForSteer(): void { - void this.deps.runtime.cancel().catch(() => undefined) + void this.deps.runtime.requestSteerCancel().catch(() => undefined) } private notifyIdle(): void { From 28b5036e97ccb89b42774ad89e7b9b83ec847f9c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:17:26 -0700 Subject: [PATCH 221/269] fix(acp): a permission Grok asks with no prompt of Orca's running is declined During a turn Grok began itself nobody asked it to act, so the request is answered cancelled at once instead of opening a card that waits, as the common pattern does. --- src/main/acp/acp-structured-acquire.ts | 4 ++++ .../acp/acp-structured-session-adapter.test.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 4ec1492f6bfd..04edf57d871a 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -120,6 +120,10 @@ export async function acquireAcpStructuredSession(input: { clientInfo: { name: 'orca', version: '1' }, cancelTimeoutMs: deps.cancelTimeoutMs ?? ACP_CANCEL_TIMEOUT_MS, onPermission: (request, context) => { + if (!session?.turns.running) { + // No prompt of Orca's runs (a turn the agent began itself): nobody is there to ask. + return { outcome: { outcome: 'cancelled' } } + } if (launch.fullAccess) { // Full access: Orca answers yes for the person, as the agent's own bypass flag would. const allow = request.options.find((option) => option.kind === 'allow_once') diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 8955a8d0773b..263eca920ec7 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -227,6 +227,21 @@ describe('ACP structured session adapter: approvals', () => { }) ).rejects.toThrow(/no longer waiting/) }) + + it('answers cancelled a permission Grok asks during a turn it began itself, with no card', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + agent.notify('session/update', chunk('task-completed-background-1', 'Build finished; now')) + await rig.settle() + const reply = await agent.request(3, 'session/request_permission', { + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId: 'call-1', title: 'Write file' }, + options: [{ optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }] + }) + expect(reply).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect((await rig.rig.rows()).some((row) => row.body.kind === 'approval')).toBe(false) + }) }) describe('ACP structured session adapter: requests a Stop cancels', () => { From a6bd84061673017760686d669829a32cf7219379 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:18:50 -0700 Subject: [PATCH 222/269] fix(acp): a Grok that dies while starting is reported with its own last words A dying process's stdout ends before its exit is seen, so the start failed as a closed connection and Grok's stderr was lost. A start whose connection closed now waits, bounded by the Stop grace (or a Close/Stop), for the exit before it is told. --- src/main/acp/acp-structured-child.ts | 21 +++++++++++++++++++ .../acp-structured-session-adapter.test.ts | 16 ++++++++++++++ .../acp/acp-structured-session-adapter.ts | 7 +++++++ 3 files changed, 44 insertions(+) diff --git a/src/main/acp/acp-structured-child.ts b/src/main/acp/acp-structured-child.ts index b125a9404e1f..fa5c7baf1eca 100644 --- a/src/main/acp/acp-structured-child.ts +++ b/src/main/acp/acp-structured-child.ts @@ -63,3 +63,24 @@ export function spawnAcpStructuredChild( } } } + +/** Resolves once `child` exits, `signal` aborts, or `ms` pass, whichever is first. */ +export function waitForAcpChildExit( + child: AcpStructuredChild, + ms: number, + signal: AbortSignal +): Promise { + if (child.exited || signal.aborted) { + return Promise.resolve() + } + return new Promise((resolve) => { + const done = (): void => { + clearTimeout(timer) + signal.removeEventListener('abort', done) + resolve() + } + const timer = setTimeout(done, ms) + signal.addEventListener('abort', done, { once: true }) + child.onExit(done) + }) +} diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 263eca920ec7..a18181bdbbbc 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -114,6 +114,22 @@ it('reports an agent that exits while starting with its own last words', async ( expect(rig.lifecycle).toEqual([]) }) +it('reads those last words when the agent ends its stdout before its exit is seen', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/new', () => { + const child = rig.child() + child.stderr = 'grok: config.toml is invalid' + // On POSIX a dying process's stdout ends first; its exit is observed a moment later. + child.agent.stdout.end() + setTimeout(() => child.exit(), 5) + }) + }) + const failure = await rig.acquire().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AgentSessionAcquisitionExitProvenError) + expect(failure).toMatchObject({ message: 'grok: config.toml is invalid' }) +}) + describe('ACP structured session adapter: turns', () => { it('sends a prompt under an injected id and settles it on the agent first event', async () => { const rig = await openAcpAdapterRig() diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 009b36df044b..27d4d1d262c5 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -28,6 +28,8 @@ import { type AcpStructuredSession } from './acp-structured-session' import { AcpStructuredStarts, type AcpStartAttempt } from './acp-structured-starts' +import { waitForAcpChildExit } from './acp-structured-child' +import { AcpConnectionClosedError } from './acp-errors' import { acpPromptBlocks } from './acp-structured-turns' import { ACP_OPTION_WRITE_TIMEOUT_MS, @@ -94,6 +96,11 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return acquisition } catch (error) { const { child } = attempt + if (child && error instanceof AcpConnectionClosedError) { + // A dying agent's stdout ends before its exit is seen: wait (bounded) for that exit, so + // the failure carries its last words, as a running session's end does. + await waitForAcpChildExit(child, this.deps.stopGraceMs ?? ACP_STOP_GRACE_MS, attempt.signal) + } // Checked before the close below, which would make any exit look like one Orca asked for. const exitedOnItsOwn = child?.exited === true && !attempt.signal.aborted if (child && !(await child.close().catch(() => false))) { From 212d0d2395005ebecf0c95fb06dab70fe365a07e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:25:30 -0700 Subject: [PATCH 223/269] test: a Stop after a steer sends its own cancel; drop the import the A3 merge doubled --- src/main/acp/acp-structured-session-adapter-lifecycle.test.ts | 3 ++- .../structured-agent-session-codex-stop-row.test.ts | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index d210ad6c23a1..712d288b859b 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -104,7 +104,8 @@ describe('ACP Stop names a turn', () => { resolveLiveTurnId: () => live }) ).resolves.toEqual({ cancelled: true }) - expect(rig.sent('session/cancel')).toHaveLength(1) + // The steer's cancel, then the Stop's own bounded one. + expect(rig.sent('session/cancel')).toHaveLength(2) expect(rig.settled).toEqual( expect.arrayContaining([ expect.objectContaining({ clientMessageId: 'follow-up', state: 'rejected' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 2e93cd79c059..f6d7d6d4be7e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -29,7 +29,6 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } From 10673e9ec2ccfd951c82052607c32df9cd07e56d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:28:45 -0700 Subject: [PATCH 224/269] test(native-chat): main's Stop-note test builds its turn context with the agent registry --- .../structured-agent-session-stop-note-opened-turn.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts index bd425db0824d..d8a6aacaa99d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts @@ -15,13 +15,15 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-a import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const TURN = { provider: 'codex' as const, threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 } @@ -47,6 +49,8 @@ async function stopWhileTheTurnOpens( sessionId: 'session-1', journal, fence: 1, + agents: claudeAndCodexDeclared(), + agent: 'codex', adapter: { acquire: vi.fn(), dispatch: vi.fn(), From d47e26e8b1e608755c0447037c50f30af432fcdf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:30:45 -0700 Subject: [PATCH 225/269] test(claude): say why the close test's fake child cast is safe --- src/main/claude/claude-stream-json-connection-close.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/claude/claude-stream-json-connection-close.test.ts b/src/main/claude/claude-stream-json-connection-close.test.ts index 7c4c701a273e..bbf0a4ca1650 100644 --- a/src/main/claude/claude-stream-json-connection-close.test.ts +++ b/src/main/claude/claude-stream-json-connection-close.test.ts @@ -33,6 +33,7 @@ function fakeChild( } ): ChildProcessWithoutNullStreams { const child = new EventEmitter() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The connection reads only pid, the three pipes and kill, which this fake carries. return Object.assign(child, { pid: 424242, stdin: new PassThrough(), From fb8bcb8ad31f97ed5158a7f6e7e7710131b49c96 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:55:58 -0700 Subject: [PATCH 226/269] test(native-chat): build the Stop-opened-turn test's identity and turn context the current way The test (#25056) landed before the opaque provider handle (#24991), so main still built the old {kind, threadId} handle; the turn context also needs this branch's agent registry. --- .../structured-agent-session-stop-note-opened-turn.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts index bd425db0824d..66af1b8b8ef4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts @@ -10,8 +10,10 @@ import { AGENT_JOURNAL_THREAD_SCOPE, type AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' @@ -21,7 +23,7 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const TURN = { provider: 'codex' as const, threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 } @@ -47,6 +49,8 @@ async function stopWhileTheTurnOpens( sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { acquire: vi.fn(), dispatch: vi.fn(), From 5c6a834abb2f0e0ed6d7a8fdb937142583ca81a9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:58:40 -0700 Subject: [PATCH 227/269] test(native-chat): build the Stop-opened-turn test's identity with the opaque handle The test (#25056) landed before the opaque provider handle (#24991), so main still built the old {kind, threadId} handle. --- .../structured-agent-session-stop-note-opened-turn.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts index bd425db0824d..f8c2256090c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts @@ -10,6 +10,7 @@ import { AGENT_JOURNAL_THREAD_SCOPE, type AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' @@ -21,7 +22,7 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const TURN = { provider: 'codex' as const, threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 } From e35bf185aedc3ad7c3df69ca2f536748374cb51f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:13:22 -0700 Subject: [PATCH 228/269] test(ratchet): require src/main/provider-process now that it has landed --- config/scripts/check-runtime-electron-ratchet.mjs | 6 +++--- config/scripts/check-runtime-electron-ratchet.test.mjs | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs index 19810c2f4020..3e04b8c46f25 100644 --- a/config/scripts/check-runtime-electron-ratchet.mjs +++ b/config/scripts/check-runtime-electron-ratchet.mjs @@ -48,9 +48,9 @@ export const STRUCTURED_CHAT_LANES = [ { directory: ['src', 'main', 'codex'] }, { directory: ['src', 'shared'] }, { directory: ['src', 'main', 'runtime'], basename: /^(?:structured-|agent-session-)/ }, - // Allowed absent until they land; every other lane throws if missing, so a rename can't empty it. - { directory: ['src', 'main', 'acp'], mayBeAbsent: true }, - { directory: ['src', 'main', 'provider-process'], mayBeAbsent: true } + { directory: ['src', 'main', 'provider-process'] }, + // Allowed absent until it lands; every other lane throws if missing, so a rename can't empty it. + { directory: ['src', 'main', 'acp'], mayBeAbsent: true } ] export function collectStructuredChatEntryPoints(root = ROOT) { diff --git a/config/scripts/check-runtime-electron-ratchet.test.mjs b/config/scripts/check-runtime-electron-ratchet.test.mjs index 1f4f37c01a90..a8985f0c014f 100644 --- a/config/scripts/check-runtime-electron-ratchet.test.mjs +++ b/config/scripts/check-runtime-electron-ratchet.test.mjs @@ -32,12 +32,13 @@ describe('structured chat coverage', () => { return root } - // Every lane that must exist; acp/ and provider-process/ may be absent until they land. + // Every lane that must exist; acp/ may be absent until it lands. const requiredLanes = { 'src/main/native-chat/reader.ts': 'export {}', 'src/main/claude/claude-session.ts': 'export {}', 'src/main/codex/codex-session.ts': 'export {}', 'src/main/runtime/structured-agent-session-host.ts': 'export {}', + 'src/main/provider-process/provider-process-teardown.ts': 'export {}', 'src/shared/agent-session-record.ts': 'export {}' } @@ -89,7 +90,7 @@ describe('structured chat coverage', () => { Object.entries(requiredLanes).filter(([file]) => !file.startsWith(`${lane}/`)) ) expect(() => collectStructuredChatEntryPoints(fixture(without))).toThrow(`${lane} is missing`) - expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(5) + expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(6) } ) @@ -128,7 +129,7 @@ describe('the default entry points', () => { } }) - // Retires the temporary flag: the PR that adds acp/ or provider-process/ must make it required. + // Retires the temporary flag: the PR that adds acp/ must make it required. it('lets only directories that have not landed yet be absent', () => { for (const lane of STRUCTURED_CHAT_LANES.filter((candidate) => candidate.mayBeAbsent)) { expect( From 5b759286bc86694331ac364052480b8c3a889072 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:16:59 -0700 Subject: [PATCH 229/269] test(native-chat): keep main's opaque-handle import in the Stop-opened-turn test Main's #25706 and this branch both added the import at different lines; the merge kept both. --- .../structured-agent-session-stop-note-opened-turn.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts index 268b4f28f781..d514035812f2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts @@ -11,7 +11,6 @@ import { AGENT_JOURNAL_THREAD_SCOPE, type AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' From 1a5c70e7db3b29dee91ad6db8e16661699d82ebe Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:18:07 -0700 Subject: [PATCH 230/269] test(native-chat): keep main's opaque-handle import in the Stop-opened-turn test Main's #25706 made the same fix as this branch at a different line; the merge kept both imports. --- .../structured-agent-session-stop-note-opened-turn.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts index c177e7e2c8c0..f8df44ce3073 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts @@ -11,7 +11,6 @@ import { AGENT_JOURNAL_THREAD_SCOPE, type AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' From 1f7f7fab0ce94761ec5cadadbc37a09832503aea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:49:18 -0700 Subject: [PATCH 231/269] feat(native-chat): record a fresh provider conversation that replaced one the agent could not restore A chat whose saved conversation the agent cannot reopen can now continue in a fresh one: the handle chain records the new conversation as a creation that replaces the lost one (which, why, and when), keeping every earlier link. Rows keep a shape older builds read: the stored chain starts at the latest replacement and carries the earlier links inside it. --- ...ent-session-provider-handle-replacement.ts | 61 ++++++ .../agent-session-provider-handle.test.ts | 145 ++++++++++++++ src/shared/agent-session-provider-handle.ts | 147 +++++++++++--- .../agent-session-record-stored-form.test.ts | 105 +++++++++- ...-handle-replacement-downgrade.unit.test.ts | 182 ++++++++++++++++++ 5 files changed, 608 insertions(+), 32 deletions(-) create mode 100644 src/shared/agent-session-provider-handle-replacement.ts create mode 100644 tests/e2e/cross-version-wire/agent-session-handle-replacement-downgrade.unit.test.ts diff --git a/src/shared/agent-session-provider-handle-replacement.ts b/src/shared/agent-session-provider-handle-replacement.ts new file mode 100644 index 000000000000..cc235dc6d287 --- /dev/null +++ b/src/shared/agent-session-provider-handle-replacement.ts @@ -0,0 +1,61 @@ +/** + * What a chain link records when the provider could not restore a chat's saved conversation and a + * fresh one took over. + */ + +import type { AgentSessionProviderHandle } from './agent-session-provider-handle' +import { isAgentSessionProviderHandleKeyFor } from './agent-session-provider-handle-encoding' + +const MAX_REPLACEMENT_REASON_LENGTH = 64 + +/** + * Why a chat's agent no longer remembers what came before this link: the provider could not + * restore the conversation the chat had, so a fresh one continues it without that history. + */ +export type AgentSessionProviderHandleReplacement = { + /** Key of the conversation that was lost: the head this creation followed. */ + key: string + /** Open: a later build may record a reason this one does not name, and it must stay readable. */ + reason: 'restore-failed' | (string & {}) + /** When the replacement was made; a link's `observedAt` moves with its resume point. */ + replacedAt: number +} + +export function isAgentSessionProviderHandleReplacement( + handle: AgentSessionProviderHandle, + value: unknown +): value is AgentSessionProviderHandleReplacement { + if ( + typeof value !== 'object' || + value === null || + !('key' in value) || + !('reason' in value) || + !('replacedAt' in value) + ) { + return false + } + const { key, reason, replacedAt } = value + return ( + isAgentSessionProviderHandleKeyFor(handle, key) && + typeof reason === 'string' && + reason.length > 0 && + reason.length <= MAX_REPLACEMENT_REASON_LENGTH && + typeof replacedAt === 'number' && + Number.isSafeInteger(replacedAt) && + replacedAt >= 0 + ) +} + +export function agentSessionProviderHandleReplacementsEqual( + left: AgentSessionProviderHandleReplacement | undefined, + right: AgentSessionProviderHandleReplacement | undefined +): boolean { + return ( + left === right || + (left !== undefined && + right !== undefined && + left.key === right.key && + left.reason === right.reason && + left.replacedAt === right.replacedAt) + ) +} diff --git a/src/shared/agent-session-provider-handle.test.ts b/src/shared/agent-session-provider-handle.test.ts index d66c03568151..a8c57654182c 100644 --- a/src/shared/agent-session-provider-handle.test.ts +++ b/src/shared/agent-session-provider-handle.test.ts @@ -519,3 +519,148 @@ describe('a transport shared code has never heard of', () => { } }) }) + +describe('replacing a conversation the provider could not restore', () => { + const acp = (nativeId: string): AgentSessionProviderHandle => ({ + transport: 'acp', + agent: 'grok', + nativeId + }) + const created = link({ handle: acp('s-1') }) + const resumed = link({ + linkId: 'link-2', + origin: 'resumed', + handle: acp('s-1'), + mintedAtFence: 2 + }) + const lost = { + key: agentSessionProviderHandleKey(acp('s-1')), + reason: 'restore-failed', + replacedAt: 3_000 + } + function fresh(overrides: Partial = {}) { + return link({ + linkId: 'link-3', + handle: acp('s-2'), + mintedAtFence: 3, + observedAt: 3_000, + replaces: lost, + ...overrides + }) + } + + it('follows a reopened conversation and keeps every link before it', () => { + const chain = appendAgentSessionProviderHandleLink([created, resumed], fresh()) + expect(chain).toEqual([created, resumed, fresh()]) + expect(agentSessionProviderHandleChainHead(chain)?.handle.nativeId).toBe('s-2') + expect(isAgentSessionProviderHandleChain(chain)).toBe(true) + // The fresh conversation then resumes like any other. + const reopened = appendAgentSessionProviderHandleLink( + chain, + link({ linkId: 'link-4', origin: 'resumed', handle: acp('s-2'), mintedAtFence: 4 }) + ) + expect(reopened).toHaveLength(4) + expect(isAgentSessionProviderHandleChain(reopened)).toBe(true) + }) + + it('names exactly the head it took over from, on a new root', () => { + for (const bad of [ + fresh({ replaces: { ...lost, key: agentSessionProviderHandleKey(acp('s-9')) } }), + fresh({ handle: acp('s-1') }), + fresh({ linkId: 'link-2' }), + fresh({ replaces: undefined }) + ]) { + expect(() => appendAgentSessionProviderHandleLink([created, resumed], bad)).toThrow( + 'agent_session_provider_handle_invalid' + ) + } + expect(() => + appendAgentSessionProviderHandleLink([created, resumed], fresh({ mintedAtFence: 1 })) + ).toThrow('agent_session_provider_handle_stale_fence') + }) + + it('is only ever a creation, and never opens a chain', () => { + expect(() => appendAgentSessionProviderHandleLink([], fresh())).toThrow( + 'agent_session_provider_handle_invalid' + ) + for (const origin of ['resumed', 'adopted', 'forked'] as const) { + expect(() => + appendAgentSessionProviderHandleLink( + [created, resumed], + fresh({ origin, forkedFromKey: lost.key }) + ) + ).toThrow('agent_session_provider_handle_invalid') + } + }) + + it('reads a reason a later build records, but no malformed one', () => { + const later = fresh({ replaces: { ...lost, reason: 'transport-retired' } }) + expect(appendAgentSessionProviderHandleLink([created, resumed], later)).toHaveLength(3) + for (const replaces of [ + { ...lost, reason: '' }, + { ...lost, reason: 'x'.repeat(65) }, + { ...lost, replacedAt: -1 }, + { key: lost.key, reason: lost.reason } + ]) { + expect(() => + appendAgentSessionProviderHandleLink( + [created, resumed], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: deliberately malformed stored data the guard must refuse. + fresh({ replaces: replaces as AgentSessionProviderHandleLink['replaces'] }) + ) + ).toThrow('agent_session_provider_handle_invalid') + } + }) + + it('stays lost when a fresh conversation the agent never saved is superseded in turn', () => { + const chain = appendAgentSessionProviderHandleLink([created, resumed], fresh()) + const superseding = link({ + linkId: 'link-5', + handle: acp('s-3'), + mintedAtFence: 5, + supersedesKey: agentSessionProviderHandleKey(acp('s-2')) + }) + const superseded = appendAgentSessionProviderHandleLink(chain, superseding) + expect(superseded).toEqual([created, resumed, { ...superseding, replaces: lost }]) + expect(isAgentSessionProviderHandleChain(superseded)).toBe(true) + // Unused across many restarts, it still does not grow. + const again = appendAgentSessionProviderHandleLink(superseded, { + ...superseding, + linkId: 'link-6', + handle: acp('s-4'), + mintedAtFence: 6, + supersedesKey: agentSessionProviderHandleKey(acp('s-3')) + }) + expect(again).toHaveLength(3) + expect(again.at(-1)?.replaces).toEqual(lost) + // A supersession cannot rewrite what was lost, nor land back on it. + expect(() => + appendAgentSessionProviderHandleLink(chain, { + ...superseding, + replaces: { ...lost, replacedAt: 9 } + }) + ).toThrow('agent_session_provider_handle_invalid') + expect(() => + appendAgentSessionProviderHandleLink(chain, { ...superseding, handle: acp('s-1') }) + ).toThrow('agent_session_provider_handle_invalid') + }) + + it('still supersedes only a creation the provider never saved', () => { + const chain = appendAgentSessionProviderHandleLink([created, resumed], fresh()) + const proven = appendAgentSessionProviderHandleLink( + chain, + link({ linkId: 'link-4', origin: 'resumed', handle: acp('s-2'), mintedAtFence: 4 }) + ) + expect(() => + appendAgentSessionProviderHandleLink( + proven, + link({ + linkId: 'link-5', + handle: acp('s-3'), + mintedAtFence: 5, + supersedesKey: agentSessionProviderHandleKey(acp('s-2')) + }) + ) + ).toThrow('agent_session_provider_handle_invalid') + }) +}) diff --git a/src/shared/agent-session-provider-handle.ts b/src/shared/agent-session-provider-handle.ts index 3bf8fb69b06c..33a00ede75fe 100644 --- a/src/shared/agent-session-provider-handle.ts +++ b/src/shared/agent-session-provider-handle.ts @@ -9,10 +9,17 @@ * Resumes extend the chain, forks start a new identity root, and the chain records which is which * so a fork is never presented as a resume. A creation the provider never saved can be superseded * by a new creation, which takes its place instead of standing beside it: the unsaved handle was - * never a conversation to continue. + * never a conversation to continue. A saved conversation the provider could not restore is instead + * replaced: a new creation follows it and names it, so the chain still says what the agent forgot + * and when. */ import type { AgentType } from './agent-status-types' +import { + agentSessionProviderHandleReplacementsEqual, + isAgentSessionProviderHandleReplacement, + type AgentSessionProviderHandleReplacement +} from './agent-session-provider-handle-replacement' import { agentSessionProviderHandleKey, agentSessionProviderHandleRoot, @@ -24,6 +31,7 @@ import { type PersistedAgentSessionProviderHandle } from './agent-session-provider-handle-encoding' +export type { AgentSessionProviderHandleReplacement } from './agent-session-provider-handle-replacement' export { agentSessionProviderHandleKey, agentSessionProviderHandleRoot, @@ -80,6 +88,8 @@ export type AgentSessionProviderHandleLink = { forkedFromKey?: string /** Key of the unsaved creation this creation replaced. Only set when `origin` is `created`. */ supersedesKey?: string + /** The saved conversation this creation took over from. Only set when `origin` is `created`. */ + replaces?: AgentSessionProviderHandleReplacement } export type AgentSessionProviderHandleChain = readonly AgentSessionProviderHandleLink[] @@ -142,7 +152,10 @@ export function isAgentSessionProviderHandleLink( ? isAgentSessionProviderHandleKeyFor(handle, link.forkedFromKey) : link.forkedFromKey === undefined) && (link.supersedesKey === undefined || - (link.origin === 'created' && isAgentSessionProviderHandleKeyFor(handle, link.supersedesKey))) + (link.origin === 'created' && + isAgentSessionProviderHandleKeyFor(handle, link.supersedesKey))) && + (link.replaces === undefined || + (link.origin === 'created' && isAgentSessionProviderHandleReplacement(handle, link.replaces))) ) } @@ -184,7 +197,8 @@ export function appendAgentSessionProviderHandleLink( } const head = agentSessionProviderHandleChainHead(chain) if (!head) { - if (link.origin !== 'created' && link.origin !== 'adopted') { + // A replacement names the conversation before it, so it can never open a chain. + if ((link.origin !== 'created' && link.origin !== 'adopted') || link.replaces !== undefined) { throw new Error('agent_session_provider_handle_invalid') } return [link] @@ -195,14 +209,30 @@ export function appendAgentSessionProviderHandleLink( if (link.mintedAtFence < head.mintedAtFence) { throw new Error('agent_session_provider_handle_stale_fence') } - if (link.origin === 'created' && link.supersedesKey !== undefined) { - return supersedeUnsavedCreation(head, link) + const sameRoot = + agentSessionProviderHandleRoot(link.handle) === agentSessionProviderHandleRoot(head.handle) + if (link.origin === 'created') { + // A replacement's `supersedesKey` names a creation already gone from the chain; only one that + // names the head is a supersession now. + if ( + link.supersedesKey !== undefined && + (link.replaces === undefined || + link.supersedesKey === agentSessionProviderHandleKey(head.handle)) + ) { + return supersedeUnsavedCreation(chain, head, link) + } + if ( + link.replaces === undefined || + link.replaces.key !== agentSessionProviderHandleKey(head.handle) || + sameRoot + ) { + throw new Error('agent_session_provider_handle_invalid') + } + return appendNewLink(chain, link) } - if (link.origin === 'created' || link.origin === 'adopted') { + if (link.origin === 'adopted') { throw new Error('agent_session_provider_handle_invalid') } - const sameRoot = - agentSessionProviderHandleRoot(link.handle) === agentSessionProviderHandleRoot(head.handle) if (link.origin === 'resumed' && !sameRoot) { // Why: a resume that lands on another identity root forked; recording it as a resume would // make Orca claim continuity the provider never gave. @@ -224,6 +254,13 @@ export function appendAgentSessionProviderHandleLink( // Why: re-proving the same handle at the same fence is a retry, not a new identity. return [...chain] } + return appendNewLink(chain, link) +} + +function appendNewLink( + chain: AgentSessionProviderHandleChain, + link: AgentSessionProviderHandleLink +): AgentSessionProviderHandleLink[] { if (findAgentSessionProviderHandleLink(chain, link.linkId)) { // Why: the lease names its exact proof by link id; reuse would make that reference ambiguous. throw new Error('agent_session_provider_handle_invalid') @@ -237,63 +274,113 @@ export function appendAgentSessionProviderHandleLink( } /** - * Replace the chain's only link, a creation the provider proved it never saved, with the creation - * that took its place. Every other head names a conversation the provider held (a resume or fork - * proved it, an adoption imported it), so only a `created` head can be superseded, and only by a - * new identity root that names it. + * Replace the head, a creation the provider proved it never saved, with the creation that took its + * place. Every other head names a conversation the provider held (a resume or fork proved it, an + * adoption imported it), so only a `created` head can be superseded, and only by a new identity + * root that names it. A superseded replacement passes on what it replaced: the conversation that + * was lost is still lost. */ function supersedeUnsavedCreation( + chain: AgentSessionProviderHandleChain, head: AgentSessionProviderHandleLink, link: AgentSessionProviderHandleLink ): AgentSessionProviderHandleLink[] { + const earlier = chain.slice(0, -1) if ( head.origin !== 'created' || link.supersedesKey !== agentSessionProviderHandleKey(head.handle) || agentSessionProviderHandleRoot(link.handle) === agentSessionProviderHandleRoot(head.handle) || + (link.replaces !== undefined && + !agentSessionProviderHandleReplacementsEqual(link.replaces, head.replaces)) || link.linkId === head.linkId ) { throw new Error('agent_session_provider_handle_invalid') } + const next = head.replaces ? { ...link, replaces: head.replaces } : link // Why: in place, so a chat reopened unused across many restarts never grows toward the cap. - return [link] + return earlier.length === 0 ? [next] : appendAgentSessionProviderHandleLink(earlier, next) } // ─── Stored form ──────────────────────────────────────────────────────────── +// +// Builds before replacements existed refuse a creation anywhere but first in a chain, and only a +// row they can read survives a downgrade. So the row stores the chain from its latest replacement +// on, and that replacement carries every earlier link inside `replaces.chain`, which they keep as +// an unknown field. They resume the right conversation; only the earlier links are out of sight. /** A link as a record row stores it: only the handle has a stored form of its own. */ export type PersistedAgentSessionProviderHandleLink = Omit< AgentSessionProviderHandleLink, - 'handle' + 'handle' | 'replaces' > & { handle: PersistedAgentSessionProviderHandle + replaces?: AgentSessionProviderHandleReplacement & { + /** The stored chain this replacement followed; the in-memory chain holds it in line. */ + chain: PersistedAgentSessionProviderHandleLink[] + } } export function encodePersistedAgentSessionProviderHandleChain( chain: AgentSessionProviderHandleChain ): PersistedAgentSessionProviderHandleLink[] { - return chain.map((link) => ({ - ...link, - handle: encodePersistedAgentSessionProviderHandle(link.handle) - })) + let start = chain.length - 1 + while (start > 0 && chain[start]?.replaces === undefined) { + start -= 1 + } + const stored = chain.slice(Math.max(start, 0)).map(encodePersistedLink) + const replaces = chain[start]?.replaces + if (start > 0 && replaces && stored[0]) { + const earlier = encodePersistedAgentSessionProviderHandleChain(chain.slice(0, start)) + stored[0] = { ...stored[0], replaces: { ...replaces, chain: earlier } } + } + return stored +} + +function encodePersistedLink( + link: AgentSessionProviderHandleLink +): PersistedAgentSessionProviderHandleLink { + const { replaces: _inLine, ...stored } = link + return { ...stored, handle: encodePersistedAgentSessionProviderHandle(link.handle) } } /** The in-memory chain a stored one names, or null when any link or the chain itself is invalid. */ export function decodePersistedAgentSessionProviderHandleChain( value: unknown ): AgentSessionProviderHandleLink[] | null { - if (!Array.isArray(value) || value.length > MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { - return null - } const decoded: unknown[] = [] - for (const link of value) { - const handle = - typeof link === 'object' && link !== null && 'handle' in link - ? decodePersistedAgentSessionProviderHandle(link.handle) - : null - if (!handle) { - return null + return decodePersistedLinks(value, decoded, 0) && isAgentSessionProviderHandleChain(decoded) + ? decoded + : null +} + +function decodePersistedLinks(value: unknown, decoded: unknown[], depth: number): boolean { + if (!Array.isArray(value) || depth > MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { + return false + } + for (const [index, link] of value.entries()) { + if (typeof link !== 'object' || link === null || !('handle' in link)) { + return false + } + const handle = decodePersistedAgentSessionProviderHandle(link.handle) + const replaces: unknown = 'replaces' in link ? link.replaces : undefined + const nested = + typeof replaces === 'object' && replaces !== null && 'chain' in replaces ? replaces : null + // Only a row's first link carries earlier ones; a link inside it never does. + if ( + !handle || + (nested && (index > 0 || !decodePersistedLinks(nested.chain, decoded, depth + 1))) + ) { + return false + } + if (decoded.length >= MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { + return false + } + if (nested) { + const { chain: _chain, ...replacement } = nested + decoded.push({ ...link, handle, replaces: replacement }) + } else { + decoded.push({ ...link, handle }) } - decoded.push({ ...link, handle }) } - return isAgentSessionProviderHandleChain(decoded) ? decoded : null + return true } diff --git a/src/shared/agent-session-record-stored-form.test.ts b/src/shared/agent-session-record-stored-form.test.ts index 22ef2b54a9ad..fcc8181799c1 100644 --- a/src/shared/agent-session-record-stored-form.test.ts +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { isAgentSessionHandleProvider } from './agent-session-provider-handle' +import { + decodePersistedAgentSessionProviderHandleChain, + isAgentSessionHandleProvider, + MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS, + type AgentSessionProviderHandleLink +} from './agent-session-provider-handle' import { agentSessionJournalProviderHandle, agentSessionProviderHandleFromWire, @@ -11,7 +16,7 @@ import { decodePersistedAgentSessionProviderHandle, encodePersistedAgentSessionProviderHandle } from './agent-session-provider-handle-encoding' -import { isPersistedAgentSessionRecord } from './agent-session-record' +import { isPersistedAgentSessionRecord, type AgentSessionRecord } from './agent-session-record' import { decodePersistedAgentSessionRecord, encodeAgentSessionRecord @@ -316,3 +321,99 @@ describe('the wire and journal forms', () => { ).toEqual({ kind: 'opaque', agent: 'grok', value: 's' }) }) }) + +describe('a chat whose saved conversation could not be restored', () => { + const lostKey = (threadId: string) => agentSessionProviderHandleKey(codexProviderHandle(threadId)) + const link = ( + linkId: string, + threadId: string, + fence: number, + extra: Partial = {} + ): AgentSessionProviderHandleLink => ({ + linkId, + handle: codexProviderHandle(threadId), + origin: 'created', + mintedAtFence: fence, + observedAt: fence * 1_000, + ...extra + }) + // Opened, reopened, lost and replaced twice, and the second replacement reopened. + const chain: AgentSessionProviderHandleLink[] = [ + link('l1', 'thread-1', 1), + link('l2', 'thread-1', 2, { origin: 'resumed' }), + link('l3', 'thread-2', 3, { + replaces: { key: lostKey('thread-1'), reason: 'restore-failed', replacedAt: 3_000 } + }), + link('l4', 'thread-3', 4, { + replaces: { key: lostKey('thread-2'), reason: 'restore-failed', replacedAt: 4_000 } + }), + link('l5', 'thread-3', 7, { origin: 'resumed' }) + ] + const record: AgentSessionRecord = { + ...agentSessionRecordFixture(agentSessionLeaseFixture({ provenHandleLinkId: 'l5' })), + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' }, + providerHandleChain: chain + } + + it('stores the chain from the latest replacement on, with each earlier one inside it', () => { + const stored = JSON.parse(JSON.stringify(encodeAgentSessionRecord(record))) + const ids = (links: { linkId: string }[]) => links.map(({ linkId }) => linkId) + expect(ids(stored.providerHandleChain)).toEqual(['l4', 'l5']) + const second = stored.providerHandleChain[0].replaces + expect(second).toMatchObject({ key: lostKey('thread-2'), reason: 'restore-failed' }) + expect(ids(second.chain)).toEqual(['l3']) + expect(ids(second.chain[0].replaces.chain)).toEqual(['l1', 'l2']) + expect(stored.providerHandleChain[1].replaces).toBeUndefined() + // Every stored handle keeps the typed form older builds read. + expect(second.chain[0].replaces.chain[0].handle).toEqual({ + provider: 'codex', + threadId: 'thread-1' + }) + + expect(isPersistedAgentSessionRecord(stored)).toBe(true) + if (!isPersistedAgentSessionRecord(stored)) { + return + } + expect(decodePersistedAgentSessionRecord(stored).record).toEqual(record) + }) + + it('keeps a row with no replacement exactly as before', () => { + const plain = { ...record, providerHandleChain: chain.slice(0, 2) } + plain.lease = { ...plain.lease, provenHandleLinkId: 'l2', runtimeFence: 2 } + expect(encodeAgentSessionRecord(plain).providerHandleChain).toEqual( + chain.slice(0, 2).map((entry) => ({ + ...entry, + handle: encodePersistedAgentSessionProviderHandle(entry.handle) + })) + ) + }) + + it('refuses a row whose earlier links are anywhere but its first link, or missing', () => { + const stored = JSON.parse(JSON.stringify(encodeAgentSessionRecord(record))) + const misplaced = structuredClone(stored) + misplaced.providerHandleChain[1].replaces = misplaced.providerHandleChain[0].replaces + delete misplaced.providerHandleChain[0].replaces + expect(isPersistedAgentSessionRecord(misplaced)).toBe(false) + + const orphaned = structuredClone(stored) + delete orphaned.providerHandleChain[0].replaces.chain + expect(isPersistedAgentSessionRecord(orphaned)).toBe(false) + + // The cap counts every link, the earlier ones inside the row included. + const resumes = (count: number) => + Array.from({ length: count }, (_, index) => ({ + ...stored.providerHandleChain[1], + linkId: `r${index}`, + mintedAtFence: 8 + index + })) + const room = MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS - chain.length + const full = [...stored.providerHandleChain, ...resumes(room)] + expect(decodePersistedAgentSessionProviderHandleChain(full)).toHaveLength( + MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS + ) + const overCap = [...stored.providerHandleChain, ...resumes(room + 1)] + expect(overCap.length).toBeLessThan(MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) + expect(decodePersistedAgentSessionProviderHandleChain(overCap)).toBeNull() + }) +}) diff --git a/tests/e2e/cross-version-wire/agent-session-handle-replacement-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-handle-replacement-downgrade.unit.test.ts new file mode 100644 index 000000000000..8510bb2f14ec --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-handle-replacement-downgrade.unit.test.ts @@ -0,0 +1,182 @@ +import { expect, test } from 'vitest' +import { + agentSessionProviderHandleKey, + codexProviderHandle +} from '../../../src/shared/agent-session-provider-handle-encoding' +import type { AgentSessionProviderHandleLink } from '../../../src/shared/agent-session-provider-handle' +import { + isPersistedAgentSessionRecord, + type AgentSessionRecord +} from '../../../src/shared/agent-session-record' +import { + decodePersistedAgentSessionRecord, + encodeAgentSessionRecord +} from '../../../src/shared/agent-session-record-stored-form' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../src/shared/agent-session-record.test-fixture' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +// The latest release: handles stored and held in their typed form, no stored-form decode. +const RELEASE_REF = 'v1.4.220' +// The main build that made handles neutral and added supersession; no release has it yet. Move to +// the first release that does. +const NEUTRAL_HANDLE_REF = 'e817b0e23747ffd6f16f2ddefea861950d82a3c0' + +function releaseExport(module: Record, name: string): T { + const value = module[name] + if (typeof value !== 'function') { + throw new Error(`the pinned build exports no ${name}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a function the pinned build exports; each caller names the signature it calls, and a changed one fails the test. + return value as T +} + +const lostKey = (threadId: string) => agentSessionProviderHandleKey(codexProviderHandle(threadId)) + +function link( + linkId: string, + threadId: string, + fence: number, + extra: Partial = {} +): AgentSessionProviderHandleLink { + return { + linkId, + handle: codexProviderHandle(threadId), + origin: 'created', + mintedAtFence: fence, + observedAt: fence * 1_000, + ...extra + } +} + +// A chat reopened, then lost twice: the agent now holds thread-3 and none of thread-1 or thread-2. +const CHAIN: AgentSessionProviderHandleLink[] = [ + link('l1', 'thread-1', 1), + link('l2', 'thread-1', 2, { origin: 'resumed' }), + link('l3', 'thread-2', 3, { + replaces: { key: lostKey('thread-1'), reason: 'restore-failed', replacedAt: 3_000 } + }), + link('l4', 'thread-3', 4, { + replaces: { key: lostKey('thread-2'), reason: 'restore-failed', replacedAt: 4_000 } + }), + link('l5', 'thread-3', 7, { origin: 'resumed' }) +] + +const RECORD: AgentSessionRecord = { + ...agentSessionRecordFixture(agentSessionLeaseFixture({ provenHandleLinkId: 'l5' })), + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' }, + providerHandleChain: CHAIN +} + +/** The row an older build wrote after reopening the chat once more, read back by this build. */ +function readBack(row: unknown): AgentSessionRecord { + const parsed: unknown = JSON.parse(JSON.stringify(row)) + if (!isPersistedAgentSessionRecord(parsed)) { + throw new Error('this build cannot read the row the older build wrote') + } + return decodePersistedAgentSessionRecord(parsed).record +} + +const REOPENED_LEASE = { runtimeFence: 9, provenHandleLinkId: 'old-reopen' } + +// Both probes load real old builds, including cold extraction and transforms. +test('a release reads, reopens and rewrites a chat whose conversation was replaced', async () => { + const row: unknown = JSON.parse(JSON.stringify(encodeAgentSessionRecord(RECORD))) + const checkout = await materializeReleaseCheckout(RELEASE_REF) + const records = await importReleaseCheckoutModule(checkout, 'src/shared/agent-session-record.ts') + const handles = await importReleaseCheckoutModule( + checkout, + 'src/shared/agent-session-provider-handle.ts' + ) + const isRecord = releaseExport<(value: unknown) => boolean>( + records, + 'isPersistedAgentSessionRecord' + ) + const append = releaseExport<(chain: unknown[], link: unknown) => unknown[]>( + handles, + 'appendAgentSessionProviderHandleLink' + ) + + expect(isRecord(row)).toBe(true) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the release's own guard just accepted this row, and it stores the in-memory shape. + const stored = row as { providerHandleChain: { handle: unknown }[]; lease: object } + // It resumes the conversation the agent holds now. + expect(stored.providerHandleChain.at(-1)?.handle).toEqual({ + provider: 'codex', + threadId: 'thread-3' + }) + const providerHandleChain = append(stored.providerHandleChain, { + linkId: 'old-reopen', + handle: { provider: 'codex', threadId: 'thread-3' }, + origin: 'resumed', + mintedAtFence: 9, + observedAt: 9_000 + }) + const rewritten = { + ...stored, + providerHandleChain, + lease: { ...stored.lease, ...REOPENED_LEASE } + } + expect(isRecord(rewritten)).toBe(true) + + // Upgraded again, nothing about what was lost went missing. + expect(readBack(rewritten).providerHandleChain).toEqual([ + ...CHAIN, + link('old-reopen', 'thread-3', 9, { origin: 'resumed' }) + ]) +}, 300_000) + +test('the build that made handles neutral reads, reopens and rewrites it too', async () => { + const row: unknown = JSON.parse(JSON.stringify(encodeAgentSessionRecord(RECORD))) + const checkout = await materializeReleaseCheckout(NEUTRAL_HANDLE_REF) + const records = await importReleaseCheckoutModule(checkout, 'src/shared/agent-session-record.ts') + const storedForm = await importReleaseCheckoutModule( + checkout, + 'src/shared/agent-session-record-stored-form.ts' + ) + const handles = await importReleaseCheckoutModule( + checkout, + 'src/shared/agent-session-provider-handle.ts' + ) + const isRecord = releaseExport<(value: unknown) => boolean>( + records, + 'isPersistedAgentSessionRecord' + ) + type OldRecord = { providerHandleChain: { handle: { nativeId: string } }[]; lease: object } + const decode = releaseExport<(value: unknown) => { record: OldRecord }>( + storedForm, + 'decodePersistedAgentSessionRecord' + ) + const encode = releaseExport<(record: OldRecord) => unknown>( + storedForm, + 'encodeAgentSessionRecord' + ) + const append = releaseExport< + (chain: unknown[], link: unknown) => OldRecord['providerHandleChain'] + >(handles, 'appendAgentSessionProviderHandleLink') + + expect(isRecord(row)).toBe(true) + const { record } = decode(row) + expect(record.providerHandleChain.at(-1)?.handle.nativeId).toBe('thread-3') + const providerHandleChain = append(record.providerHandleChain, { + linkId: 'old-reopen', + handle: codexProviderHandle('thread-3'), + origin: 'resumed', + mintedAtFence: 9, + observedAt: 9_000 + }) + const rewritten = encode({ + ...record, + providerHandleChain, + lease: { ...record.lease, ...REOPENED_LEASE } + }) + expect(isRecord(rewritten)).toBe(true) + + expect(readBack(rewritten).providerHandleChain).toEqual([ + ...CHAIN, + link('old-reopen', 'thread-3', 9, { origin: 'resumed' }) + ]) +}, 300_000) From 54c9f21b293c814288f4ef83ac10173230a56049 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:50:56 -0700 Subject: [PATCH 232/269] test(native-chat): build this stack's journal identities with main's opaque provider handle Main's #24991 replaced the {kind, ...} handle with {transport, agent, nativeId}; three test files from this stack still wrote the old shape. Same lines the downstream ACP branch uses. --- .../provider-timeline-assembler-test-support.ts | 2 +- .../structured-agent-session-late-proof-tool-ends.test.ts | 3 ++- .../structured-agent-session-transition.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts index b0e5eb833a22..6946d455314b 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -228,7 +228,7 @@ export async function openProviderTimelineRig( workspaceId: 'workspace-1', hostId: 'local', agent: AGENT, - providerHandle: { kind: 'opaque', agent: AGENT, value: 'provider-session-1' } + providerHandle: { transport: 'acp', agent: AGENT, nativeId: 'provider-session-1' } }, stateDirectory: root, now: () => 1_000 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts index 3e419d28aab8..b9093d850994 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts @@ -16,6 +16,7 @@ import { settleStaleStructuredAgentSessionState, settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'session-late-proof' const THREAD = 'thread-1' @@ -48,7 +49,7 @@ beforeEach(async () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, stateDirectory: root, now: () => 100 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts index 6698b4cef5ec..d9a152d0e6ed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -73,7 +73,7 @@ async function rig(watermarks: Partial = { workspaceId: 'workspace-1', hostId: 'local', agent: 'grok', - providerHandle: { kind: 'opaque', agent: 'grok', value: 'provider-session-1' } + providerHandle: { transport: 'acp', agent: 'grok', nativeId: 'provider-session-1' } }, stateDirectory: root, now: () => 1_000 @@ -99,7 +99,7 @@ async function owedRig() { workspaceId: 'workspace-1', hostId: 'local', agent: 'grok', - providerHandle: { kind: 'opaque', agent: 'grok', value: 'provider-session-1' } + providerHandle: { transport: 'acp', agent: 'grok', nativeId: 'provider-session-1' } }, now: () => 1_000 }) From a6923e4e9b1c6b8dbc7da509f52300ebbfa3434e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:53:11 -0700 Subject: [PATCH 233/269] docs(acp): every reattach drops the agent's replay, not only for a chat the journal holds --- src/main/acp/acp-structured-session.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts index 498f99fb2bda..a8bd9c22631c 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -66,8 +66,8 @@ export function routeAcpSessionEvent( ) } -/** While a chat the journal holds reattaches, everything the agent sends is history the journal - * already has, whatever the agent marked: marked as replay, the translator keeps only its usage. */ +/** While a chat reattaches, everything the agent sends is replayed history, whatever the agent + * marked: marked as replay, the translator drops it and keeps only its usage. */ export function asReattachHistory(params: unknown): unknown { if (typeof params !== 'object' || params === null || Array.isArray(params)) { return params From ab992ed491c93793a8974fcfcfdba3f7cb997141 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:39:37 -0700 Subject: [PATCH 234/269] refactor(native-chat): store a replaced conversation flat; refuse it where older builds read the row Older builds only read Claude and Codex records, so the nested stored form protected rows no replacement can reach while adding a cap mismatch after a downgrade. Store the chain as held, refuse a replacement in a Claude or Codex chain until one has a stored shape older builds read, and refuse a supersession key on a replacement that names no creation in the chain. --- .../agent-session-provider-handle-encoding.ts | 7 + ...ent-session-provider-handle-replacement.ts | 9 +- .../agent-session-provider-handle.test.ts | 30 +++ src/shared/agent-session-provider-handle.ts | 99 +++------ .../agent-session-record-stored-form.test.ts | 108 +++------ ...-handle-replacement-downgrade.unit.test.ts | 210 +++++------------- 6 files changed, 163 insertions(+), 300 deletions(-) diff --git a/src/shared/agent-session-provider-handle-encoding.ts b/src/shared/agent-session-provider-handle-encoding.ts index c0c2f6c6f57f..b18e43720f19 100644 --- a/src/shared/agent-session-provider-handle-encoding.ts +++ b/src/shared/agent-session-provider-handle-encoding.ts @@ -107,6 +107,13 @@ function isLegacyNamespace(handle: AgentSessionProviderHandleNamespace): boolean ) } +/** Whether builds before the neutral handle read a record of this namespace at all. */ +export function isAgentSessionProviderHandleReadByOlderBuilds( + handle: AgentSessionProviderHandleNamespace +): boolean { + return isLegacyNamespace(handle) +} + /** * An in-memory handle. A Claude or Codex handle must also fit its typed stored shape: Claude's * resume cursor is a leaf id, and Codex has none. diff --git a/src/shared/agent-session-provider-handle-replacement.ts b/src/shared/agent-session-provider-handle-replacement.ts index cc235dc6d287..f872e2acbc66 100644 --- a/src/shared/agent-session-provider-handle-replacement.ts +++ b/src/shared/agent-session-provider-handle-replacement.ts @@ -4,7 +4,10 @@ */ import type { AgentSessionProviderHandle } from './agent-session-provider-handle' -import { isAgentSessionProviderHandleKeyFor } from './agent-session-provider-handle-encoding' +import { + isAgentSessionProviderHandleKeyFor, + isAgentSessionProviderHandleReadByOlderBuilds +} from './agent-session-provider-handle-encoding' const MAX_REPLACEMENT_REASON_LENGTH = 64 @@ -36,6 +39,10 @@ export function isAgentSessionProviderHandleReplacement( } const { key, reason, replacedAt } = value return ( + // Why: older builds read Claude and Codex rows and refuse a creation anywhere but first in a + // chain, so a replacement there would set the whole chat aside after a downgrade. Every other + // agent's row is one they never read. A Claude or Codex fallback must first pick a stored shape. + !isAgentSessionProviderHandleReadByOlderBuilds(handle) && isAgentSessionProviderHandleKeyFor(handle, key) && typeof reason === 'string' && reason.length > 0 && diff --git a/src/shared/agent-session-provider-handle.test.ts b/src/shared/agent-session-provider-handle.test.ts index a8c57654182c..dd6d8f93fa7c 100644 --- a/src/shared/agent-session-provider-handle.test.ts +++ b/src/shared/agent-session-provider-handle.test.ts @@ -645,6 +645,36 @@ describe('replacing a conversation the provider could not restore', () => { ).toThrow('agent_session_provider_handle_invalid') }) + it('refuses a supersession key that names no creation in the chain', () => { + expect(() => + appendAgentSessionProviderHandleLink( + [created, resumed], + fresh({ supersedesKey: agentSessionProviderHandleKey(acp('s-9')) }) + ) + ).toThrow('agent_session_provider_handle_invalid') + }) + + it('is refused in a Claude or Codex chain, whose rows older builds read', () => { + for (const [first, lost, next] of [ + [CLAUDE, CLAUDE, claudeProviderHandle('sess-2', null)], + [codexProviderHandle('t-1'), codexProviderHandle('t-1'), codexProviderHandle('t-2')] + ] as const) { + expect(() => + appendAgentSessionProviderHandleLink( + [link({ handle: first })], + fresh({ + handle: next, + replaces: { + key: agentSessionProviderHandleKey(lost), + reason: 'restore-failed', + replacedAt: 3_000 + } + }) + ) + ).toThrow('agent_session_provider_handle_invalid') + } + }) + it('still supersedes only a creation the provider never saved', () => { const chain = appendAgentSessionProviderHandleLink([created, resumed], fresh()) const proven = appendAgentSessionProviderHandleLink( diff --git a/src/shared/agent-session-provider-handle.ts b/src/shared/agent-session-provider-handle.ts index 33a00ede75fe..321884430dad 100644 --- a/src/shared/agent-session-provider-handle.ts +++ b/src/shared/agent-session-provider-handle.ts @@ -171,7 +171,7 @@ export function isAgentSessionProviderHandleChain( if (!isAgentSessionProviderHandleLink(link)) { return false } - const next = appendAgentSessionProviderHandleLink(validated, link) + const next = appendLink(validated, link, true) // A persisted chain must name every link exactly once; retry elision belongs at append time. if (next.length !== validated.length + 1) { return false @@ -191,6 +191,15 @@ export function isAgentSessionProviderHandleChain( export function appendAgentSessionProviderHandleLink( chain: AgentSessionProviderHandleChain, link: AgentSessionProviderHandleLink +): AgentSessionProviderHandleLink[] { + return appendLink(chain, link, false) +} + +/** `supersededHead`: the replacement already took the place of a creation no longer in `chain`. */ +function appendLink( + chain: AgentSessionProviderHandleChain, + link: AgentSessionProviderHandleLink, + supersededHead: boolean ): AgentSessionProviderHandleLink[] { if (!isAgentSessionProviderHandleLink(link)) { throw new Error('agent_session_provider_handle_invalid') @@ -212,13 +221,7 @@ export function appendAgentSessionProviderHandleLink( const sameRoot = agentSessionProviderHandleRoot(link.handle) === agentSessionProviderHandleRoot(head.handle) if (link.origin === 'created') { - // A replacement's `supersedesKey` names a creation already gone from the chain; only one that - // names the head is a supersession now. - if ( - link.supersedesKey !== undefined && - (link.replaces === undefined || - link.supersedesKey === agentSessionProviderHandleKey(head.handle)) - ) { + if (link.supersedesKey !== undefined && !supersededHead) { return supersedeUnsavedCreation(chain, head, link) } if ( @@ -298,89 +301,45 @@ function supersedeUnsavedCreation( } const next = head.replaces ? { ...link, replaces: head.replaces } : link // Why: in place, so a chat reopened unused across many restarts never grows toward the cap. - return earlier.length === 0 ? [next] : appendAgentSessionProviderHandleLink(earlier, next) + return earlier.length === 0 ? [next] : appendLink(earlier, next, true) } // ─── Stored form ──────────────────────────────────────────────────────────── -// -// Builds before replacements existed refuse a creation anywhere but first in a chain, and only a -// row they can read survives a downgrade. So the row stores the chain from its latest replacement -// on, and that replacement carries every earlier link inside `replaces.chain`, which they keep as -// an unknown field. They resume the right conversation; only the earlier links are out of sight. /** A link as a record row stores it: only the handle has a stored form of its own. */ export type PersistedAgentSessionProviderHandleLink = Omit< AgentSessionProviderHandleLink, - 'handle' | 'replaces' + 'handle' > & { handle: PersistedAgentSessionProviderHandle - replaces?: AgentSessionProviderHandleReplacement & { - /** The stored chain this replacement followed; the in-memory chain holds it in line. */ - chain: PersistedAgentSessionProviderHandleLink[] - } } export function encodePersistedAgentSessionProviderHandleChain( chain: AgentSessionProviderHandleChain ): PersistedAgentSessionProviderHandleLink[] { - let start = chain.length - 1 - while (start > 0 && chain[start]?.replaces === undefined) { - start -= 1 - } - const stored = chain.slice(Math.max(start, 0)).map(encodePersistedLink) - const replaces = chain[start]?.replaces - if (start > 0 && replaces && stored[0]) { - const earlier = encodePersistedAgentSessionProviderHandleChain(chain.slice(0, start)) - stored[0] = { ...stored[0], replaces: { ...replaces, chain: earlier } } - } - return stored -} - -function encodePersistedLink( - link: AgentSessionProviderHandleLink -): PersistedAgentSessionProviderHandleLink { - const { replaces: _inLine, ...stored } = link - return { ...stored, handle: encodePersistedAgentSessionProviderHandle(link.handle) } + return chain.map((link) => ({ + ...link, + handle: encodePersistedAgentSessionProviderHandle(link.handle) + })) } /** The in-memory chain a stored one names, or null when any link or the chain itself is invalid. */ export function decodePersistedAgentSessionProviderHandleChain( value: unknown ): AgentSessionProviderHandleLink[] | null { - const decoded: unknown[] = [] - return decodePersistedLinks(value, decoded, 0) && isAgentSessionProviderHandleChain(decoded) - ? decoded - : null -} - -function decodePersistedLinks(value: unknown, decoded: unknown[], depth: number): boolean { - if (!Array.isArray(value) || depth > MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { - return false + if (!Array.isArray(value) || value.length > MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { + return null } - for (const [index, link] of value.entries()) { - if (typeof link !== 'object' || link === null || !('handle' in link)) { - return false - } - const handle = decodePersistedAgentSessionProviderHandle(link.handle) - const replaces: unknown = 'replaces' in link ? link.replaces : undefined - const nested = - typeof replaces === 'object' && replaces !== null && 'chain' in replaces ? replaces : null - // Only a row's first link carries earlier ones; a link inside it never does. - if ( - !handle || - (nested && (index > 0 || !decodePersistedLinks(nested.chain, decoded, depth + 1))) - ) { - return false - } - if (decoded.length >= MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { - return false - } - if (nested) { - const { chain: _chain, ...replacement } = nested - decoded.push({ ...link, handle, replaces: replacement }) - } else { - decoded.push({ ...link, handle }) + const decoded: unknown[] = [] + for (const link of value) { + const handle = + typeof link === 'object' && link !== null && 'handle' in link + ? decodePersistedAgentSessionProviderHandle(link.handle) + : null + if (!handle) { + return null } + decoded.push({ ...link, handle }) } - return true + return isAgentSessionProviderHandleChain(decoded) ? decoded : null } diff --git a/src/shared/agent-session-record-stored-form.test.ts b/src/shared/agent-session-record-stored-form.test.ts index fcc8181799c1..af43b89cd164 100644 --- a/src/shared/agent-session-record-stored-form.test.ts +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest' import { decodePersistedAgentSessionProviderHandleChain, + encodePersistedAgentSessionProviderHandleChain, isAgentSessionHandleProvider, - MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS, + type AgentSessionProviderHandle, type AgentSessionProviderHandleLink } from './agent-session-provider-handle' import { @@ -16,7 +17,7 @@ import { decodePersistedAgentSessionProviderHandle, encodePersistedAgentSessionProviderHandle } from './agent-session-provider-handle-encoding' -import { isPersistedAgentSessionRecord, type AgentSessionRecord } from './agent-session-record' +import { isPersistedAgentSessionRecord } from './agent-session-record' import { decodePersistedAgentSessionRecord, encodeAgentSessionRecord @@ -323,97 +324,48 @@ describe('the wire and journal forms', () => { }) describe('a chat whose saved conversation could not be restored', () => { - const lostKey = (threadId: string) => agentSessionProviderHandleKey(codexProviderHandle(threadId)) + const acp = (nativeId: string): AgentSessionProviderHandle => ({ + transport: 'acp', + agent: 'grok', + nativeId + }) const link = ( linkId: string, - threadId: string, + nativeId: string, fence: number, extra: Partial = {} ): AgentSessionProviderHandleLink => ({ linkId, - handle: codexProviderHandle(threadId), + handle: acp(nativeId), origin: 'created', mintedAtFence: fence, observedAt: fence * 1_000, ...extra }) + const lost = (nativeId: string, replacedAt: number) => ({ + key: agentSessionProviderHandleKey(acp(nativeId)), + reason: 'restore-failed', + replacedAt + }) // Opened, reopened, lost and replaced twice, and the second replacement reopened. - const chain: AgentSessionProviderHandleLink[] = [ - link('l1', 'thread-1', 1), - link('l2', 'thread-1', 2, { origin: 'resumed' }), - link('l3', 'thread-2', 3, { - replaces: { key: lostKey('thread-1'), reason: 'restore-failed', replacedAt: 3_000 } - }), - link('l4', 'thread-3', 4, { - replaces: { key: lostKey('thread-2'), reason: 'restore-failed', replacedAt: 4_000 } - }), - link('l5', 'thread-3', 7, { origin: 'resumed' }) + const chain = [ + link('l1', 's-1', 1), + link('l2', 's-1', 2, { origin: 'resumed' }), + link('l3', 's-2', 3, { replaces: lost('s-1', 3_000) }), + link('l4', 's-3', 4, { replaces: lost('s-2', 4_000) }), + link('l5', 's-3', 7, { origin: 'resumed' }) ] - const record: AgentSessionRecord = { - ...agentSessionRecordFixture(agentSessionLeaseFixture({ provenHandleLinkId: 'l5' })), - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' }, - providerHandleChain: chain - } - it('stores the chain from the latest replacement on, with each earlier one inside it', () => { - const stored = JSON.parse(JSON.stringify(encodeAgentSessionRecord(record))) - const ids = (links: { linkId: string }[]) => links.map(({ linkId }) => linkId) - expect(ids(stored.providerHandleChain)).toEqual(['l4', 'l5']) - const second = stored.providerHandleChain[0].replaces - expect(second).toMatchObject({ key: lostKey('thread-2'), reason: 'restore-failed' }) - expect(ids(second.chain)).toEqual(['l3']) - expect(ids(second.chain[0].replaces.chain)).toEqual(['l1', 'l2']) - expect(stored.providerHandleChain[1].replaces).toBeUndefined() - // Every stored handle keeps the typed form older builds read. - expect(second.chain[0].replaces.chain[0].handle).toEqual({ - provider: 'codex', - threadId: 'thread-1' - }) - - expect(isPersistedAgentSessionRecord(stored)).toBe(true) - if (!isPersistedAgentSessionRecord(stored)) { - return - } - expect(decodePersistedAgentSessionRecord(stored).record).toEqual(record) - }) - - it('keeps a row with no replacement exactly as before', () => { - const plain = { ...record, providerHandleChain: chain.slice(0, 2) } - plain.lease = { ...plain.lease, provenHandleLinkId: 'l2', runtimeFence: 2 } - expect(encodeAgentSessionRecord(plain).providerHandleChain).toEqual( - chain.slice(0, 2).map((entry) => ({ - ...entry, - handle: encodePersistedAgentSessionProviderHandle(entry.handle) - })) - ) + it('stores the chain as it is held, and reads every link back', () => { + const stored = JSON.parse(JSON.stringify(encodePersistedAgentSessionProviderHandleChain(chain))) + expect(stored).toEqual(chain) + expect(decodePersistedAgentSessionProviderHandleChain(stored)).toEqual(chain) }) - it('refuses a row whose earlier links are anywhere but its first link, or missing', () => { - const stored = JSON.parse(JSON.stringify(encodeAgentSessionRecord(record))) - const misplaced = structuredClone(stored) - misplaced.providerHandleChain[1].replaces = misplaced.providerHandleChain[0].replaces - delete misplaced.providerHandleChain[0].replaces - expect(isPersistedAgentSessionRecord(misplaced)).toBe(false) - - const orphaned = structuredClone(stored) - delete orphaned.providerHandleChain[0].replaces.chain - expect(isPersistedAgentSessionRecord(orphaned)).toBe(false) - - // The cap counts every link, the earlier ones inside the row included. - const resumes = (count: number) => - Array.from({ length: count }, (_, index) => ({ - ...stored.providerHandleChain[1], - linkId: `r${index}`, - mintedAtFence: 8 + index - })) - const room = MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS - chain.length - const full = [...stored.providerHandleChain, ...resumes(room)] - expect(decodePersistedAgentSessionProviderHandleChain(full)).toHaveLength( - MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS - ) - const overCap = [...stored.providerHandleChain, ...resumes(room + 1)] - expect(overCap.length).toBeLessThan(MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) - expect(decodePersistedAgentSessionProviderHandleChain(overCap)).toBeNull() + it('refuses a stored replacement that opens the chain or names another conversation', () => { + const stored = JSON.parse(JSON.stringify(encodePersistedAgentSessionProviderHandleChain(chain))) + expect(decodePersistedAgentSessionProviderHandleChain(stored.slice(2))).toBeNull() + stored[3].replaces.key = agentSessionProviderHandleKey(acp('s-1')) + expect(decodePersistedAgentSessionProviderHandleChain(stored)).toBeNull() }) }) diff --git a/tests/e2e/cross-version-wire/agent-session-handle-replacement-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-handle-replacement-downgrade.unit.test.ts index 8510bb2f14ec..66c51039375b 100644 --- a/tests/e2e/cross-version-wire/agent-session-handle-replacement-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-handle-replacement-downgrade.unit.test.ts @@ -1,49 +1,36 @@ import { expect, test } from 'vitest' +import { agentSessionProviderHandleKey } from '../../../src/shared/agent-session-provider-handle-encoding' import { - agentSessionProviderHandleKey, - codexProviderHandle -} from '../../../src/shared/agent-session-provider-handle-encoding' -import type { AgentSessionProviderHandleLink } from '../../../src/shared/agent-session-provider-handle' -import { - isPersistedAgentSessionRecord, - type AgentSessionRecord -} from '../../../src/shared/agent-session-record' -import { - decodePersistedAgentSessionRecord, - encodeAgentSessionRecord -} from '../../../src/shared/agent-session-record-stored-form' + encodePersistedAgentSessionProviderHandleChain, + type AgentSessionProviderHandle, + type AgentSessionProviderHandleLink +} from '../../../src/shared/agent-session-provider-handle' import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../src/shared/agent-session-record.test-fixture' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' -// The latest release: handles stored and held in their typed form, no stored-form decode. +// The latest release: handles stored and held in their typed form. const RELEASE_REF = 'v1.4.220' -// The main build that made handles neutral and added supersession; no release has it yet. Move to -// the first release that does. +// The main build that made handles neutral; no release has it yet. Move to the first that does. const NEUTRAL_HANDLE_REF = 'e817b0e23747ffd6f16f2ddefea861950d82a3c0' -function releaseExport(module: Record, name: string): T { - const value = module[name] - if (typeof value !== 'function') { - throw new Error(`the pinned build exports no ${name}`) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a function the pinned build exports; each caller names the signature it calls, and a changed one fails the test. - return value as T -} - -const lostKey = (threadId: string) => agentSessionProviderHandleKey(codexProviderHandle(threadId)) +const acp = (nativeId: string): AgentSessionProviderHandle => ({ + transport: 'acp', + agent: 'grok', + nativeId +}) function link( linkId: string, - threadId: string, + nativeId: string, fence: number, extra: Partial = {} ): AgentSessionProviderHandleLink { return { linkId, - handle: codexProviderHandle(threadId), + handle: acp(nativeId), origin: 'created', mintedAtFence: fence, observedAt: fence * 1_000, @@ -51,132 +38,53 @@ function link( } } -// A chat reopened, then lost twice: the agent now holds thread-3 and none of thread-1 or thread-2. -const CHAIN: AgentSessionProviderHandleLink[] = [ - link('l1', 'thread-1', 1), - link('l2', 'thread-1', 2, { origin: 'resumed' }), - link('l3', 'thread-2', 3, { - replaces: { key: lostKey('thread-1'), reason: 'restore-failed', replacedAt: 3_000 } - }), - link('l4', 'thread-3', 4, { - replaces: { key: lostKey('thread-2'), reason: 'restore-failed', replacedAt: 4_000 } - }), - link('l5', 'thread-3', 7, { origin: 'resumed' }) -] - -const RECORD: AgentSessionRecord = { - ...agentSessionRecordFixture(agentSessionLeaseFixture({ provenHandleLinkId: 'l5' })), - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' }, - providerHandleChain: CHAIN -} - -/** The row an older build wrote after reopening the chat once more, read back by this build. */ -function readBack(row: unknown): AgentSessionRecord { - const parsed: unknown = JSON.parse(JSON.stringify(row)) - if (!isPersistedAgentSessionRecord(parsed)) { - throw new Error('this build cannot read the row the older build wrote') - } - return decodePersistedAgentSessionRecord(parsed).record -} - -const REOPENED_LEASE = { runtimeFence: 9, provenHandleLinkId: 'old-reopen' } - -// Both probes load real old builds, including cold extraction and transforms. -test('a release reads, reopens and rewrites a chat whose conversation was replaced', async () => { - const row: unknown = JSON.parse(JSON.stringify(encodeAgentSessionRecord(RECORD))) - const checkout = await materializeReleaseCheckout(RELEASE_REF) - const records = await importReleaseCheckoutModule(checkout, 'src/shared/agent-session-record.ts') - const handles = await importReleaseCheckoutModule( - checkout, - 'src/shared/agent-session-provider-handle.ts' - ) - const isRecord = releaseExport<(value: unknown) => boolean>( - records, - 'isPersistedAgentSessionRecord' - ) - const append = releaseExport<(chain: unknown[], link: unknown) => unknown[]>( - handles, - 'appendAgentSessionProviderHandleLink' - ) - - expect(isRecord(row)).toBe(true) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the release's own guard just accepted this row, and it stores the in-memory shape. - const stored = row as { providerHandleChain: { handle: unknown }[]; lease: object } - // It resumes the conversation the agent holds now. - expect(stored.providerHandleChain.at(-1)?.handle).toEqual({ - provider: 'codex', - threadId: 'thread-3' - }) - const providerHandleChain = append(stored.providerHandleChain, { - linkId: 'old-reopen', - handle: { provider: 'codex', threadId: 'thread-3' }, - origin: 'resumed', - mintedAtFence: 9, - observedAt: 9_000 - }) - const rewritten = { - ...stored, - providerHandleChain, - lease: { ...stored.lease, ...REOPENED_LEASE } - } - expect(isRecord(rewritten)).toBe(true) - - // Upgraded again, nothing about what was lost went missing. - expect(readBack(rewritten).providerHandleChain).toEqual([ - ...CHAIN, - link('old-reopen', 'thread-3', 9, { origin: 'resumed' }) - ]) -}, 300_000) - -test('the build that made handles neutral reads, reopens and rewrites it too', async () => { - const row: unknown = JSON.parse(JSON.stringify(encodeAgentSessionRecord(RECORD))) - const checkout = await materializeReleaseCheckout(NEUTRAL_HANDLE_REF) - const records = await importReleaseCheckoutModule(checkout, 'src/shared/agent-session-record.ts') - const storedForm = await importReleaseCheckoutModule( - checkout, - 'src/shared/agent-session-record-stored-form.ts' - ) - const handles = await importReleaseCheckoutModule( - checkout, - 'src/shared/agent-session-provider-handle.ts' - ) - const isRecord = releaseExport<(value: unknown) => boolean>( - records, - 'isPersistedAgentSessionRecord' - ) - type OldRecord = { providerHandleChain: { handle: { nativeId: string } }[]; lease: object } - const decode = releaseExport<(value: unknown) => { record: OldRecord }>( - storedForm, - 'decodePersistedAgentSessionRecord' +/** The row a build that runs another agent's chats writes: its provider and handles are neutral. */ +function neutralRow(chain: AgentSessionProviderHandleLink[]): unknown { + const head = chain.at(-1) + const fixture = agentSessionRecordFixture( + agentSessionLeaseFixture({ + provenHandleLinkId: head?.linkId, + runtimeFence: head?.mintedAtFence + }) ) - const encode = releaseExport<(record: OldRecord) => unknown>( - storedForm, - 'encodeAgentSessionRecord' + return JSON.parse( + JSON.stringify({ + ...fixture, + provider: 'grok', + accountHome: { variable: 'GROK_HOME', path: '/home/user/.grok' }, + providerHandleChain: encodePersistedAgentSessionProviderHandleChain(chain) + }) ) - const append = releaseExport< - (chain: unknown[], link: unknown) => OldRecord['providerHandleChain'] - >(handles, 'appendAgentSessionProviderHandleLink') +} - expect(isRecord(row)).toBe(true) - const { record } = decode(row) - expect(record.providerHandleChain.at(-1)?.handle.nativeId).toBe('thread-3') - const providerHandleChain = append(record.providerHandleChain, { - linkId: 'old-reopen', - handle: codexProviderHandle('thread-3'), - origin: 'resumed', - mintedAtFence: 9, - observedAt: 9_000 - }) - const rewritten = encode({ - ...record, - providerHandleChain, - lease: { ...record.lease, ...REOPENED_LEASE } +const REOPENED = [link('l1', 's-1', 1), link('l2', 's-1', 2, { origin: 'resumed' })] +const REPLACED = [ + ...REOPENED, + link('l3', 's-2', 3, { + replaces: { + key: agentSessionProviderHandleKey(acp('s-1')), + reason: 'restore-failed', + replacedAt: 3_000 + } }) - expect(isRecord(rewritten)).toBe(true) +] - expect(readBack(rewritten).providerHandleChain).toEqual([ - ...CHAIN, - link('old-reopen', 'thread-3', 9, { origin: 'resumed' }) - ]) -}, 300_000) +// A replacement is stored as it is held, which older builds would refuse; it is safe only because +// the rows that can hold one are rows they already set aside. Claude and Codex chains refuse it. +test.each([RELEASE_REF, NEUTRAL_HANDLE_REF])( + '%s sets aside a chat a replacement can reach whether or not it holds one', + async (ref) => { + const checkout = await materializeReleaseCheckout(ref) + const records = await importReleaseCheckoutModule( + checkout, + 'src/shared/agent-session-record.ts' + ) + const isRecord = records.isPersistedAgentSessionRecord + if (typeof isRecord !== 'function') { + throw new Error(`${ref} exports no isPersistedAgentSessionRecord`) + } + expect(isRecord(neutralRow(REOPENED))).toBe(false) + expect(isRecord(neutralRow(REPLACED))).toBe(false) + }, + 300_000 +) From a31b528c204108522fea6b341b977bbce2eba741 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:14:21 -0700 Subject: [PATCH 235/269] refactor(native-chat): read hosts' structured agents from the app-shell services Main grew the startup hydration hook to its line limit; the host agents sync is an app-lifetime subscription like the structured session tabs sync beside it, so it moves there. --- src/renderer/src/app-shell/use-app-shell-services.ts | 2 ++ src/renderer/src/app-shell/use-app-startup-hydration.ts | 2 -- src/renderer/src/runtime/host-structured-agents-sync.ts | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index 879fcea5984a..25af5a9e32f1 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -17,6 +17,7 @@ import { useGitStatusPolling } from '../components/right-sidebar/useGitStatusPol import { useOsc52ClipboardDefaultOnNotice } from '../components/terminal-pane/osc52-clipboard-default-on-notice' import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync' import { useLocalStructuredSessionTabsSync } from '../runtime/local-structured-session-tabs-sync' +import { useHostStructuredAgentsSync } from '../runtime/host-structured-agents-sync' import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime-recovery-triggers' import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-publication' import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' @@ -39,6 +40,7 @@ export function useAppShellServices(options: { floatingPanelVisible: boolean }): useRadixBodyPointerEventsRecovery() useWebSessionTabsSync() useLocalStructuredSessionTabsSync() + useHostStructuredAgentsSync() // Subscribe to IPC push events useIpcEvents() useRemoteRuntimeRecoveryTriggers() diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 1444c2e07f89..0c0ab0dd5436 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react' import { restoreLocalStructuredChatsAtStartup } from '@/runtime/local-structured-chats' import { syncZoomCSSVar } from '@/lib/ui-zoom' import { installCodexDetachedPaneRestartExecutor } from '@/components/terminal-pane/codex-detached-pane-restart-scheduler' -import { installHostStructuredAgentsSync } from '@/runtime/host-structured-agents-sync' import { useAppStore } from '../store' import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-workspace-tab-models' import { useStartupActions } from './use-app-startup-actions' @@ -67,7 +66,6 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta }, [onOnboardingLoaded]) useEffect(() => installCodexDetachedPaneRestartExecutor(), []) - useEffect(() => installHostStructuredAgentsSync(), []) // Fetch initial data + hydrate GitHub cache from disk useEffect(() => { diff --git a/src/renderer/src/runtime/host-structured-agents-sync.ts b/src/renderer/src/runtime/host-structured-agents-sync.ts index ed181fdff3f8..f755b065db96 100644 --- a/src/renderer/src/runtime/host-structured-agents-sync.ts +++ b/src/renderer/src/runtime/host-structured-agents-sync.ts @@ -1,3 +1,4 @@ +import { useEffect } from 'react' import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId } from '../../../shared/execution-host' import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' import { useAppStore } from '@/store' @@ -59,3 +60,7 @@ export function installHostStructuredAgentsSync(): () => void { } }) } + +export function useHostStructuredAgentsSync(): void { + useEffect(() => installHostStructuredAgentsSync(), []) +} From eba27a3c784ea7e4c0a4eb3d6d6020d7962faf47 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:26:46 -0700 Subject: [PATCH 236/269] Use current provider handles in transition tests --- .../structured-agent-session-transition.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts index 6698b4cef5ec..d9a152d0e6ed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -73,7 +73,7 @@ async function rig(watermarks: Partial = { workspaceId: 'workspace-1', hostId: 'local', agent: 'grok', - providerHandle: { kind: 'opaque', agent: 'grok', value: 'provider-session-1' } + providerHandle: { transport: 'acp', agent: 'grok', nativeId: 'provider-session-1' } }, stateDirectory: root, now: () => 1_000 @@ -99,7 +99,7 @@ async function owedRig() { workspaceId: 'workspace-1', hostId: 'local', agent: 'grok', - providerHandle: { kind: 'opaque', agent: 'grok', value: 'provider-session-1' } + providerHandle: { transport: 'acp', agent: 'grok', nativeId: 'provider-session-1' } }, now: () => 1_000 }) From a7d8bcbb64fb6e60cd0cb1effe121cb1fadc67fc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:27:40 -0700 Subject: [PATCH 237/269] Use current provider handles in timeline fixtures --- .../provider-timeline-assembler-test-support.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts index b0e5eb833a22..6946d455314b 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -228,7 +228,7 @@ export async function openProviderTimelineRig( workspaceId: 'workspace-1', hostId: 'local', agent: AGENT, - providerHandle: { kind: 'opaque', agent: AGENT, value: 'provider-session-1' } + providerHandle: { transport: 'acp', agent: AGENT, nativeId: 'provider-session-1' } }, stateDirectory: root, now: () => 1_000 From 3b064e888217ac18abe92536e9bbf141f52721f8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:30:02 -0700 Subject: [PATCH 238/269] test(native-chat): prove replacement rows survive downgrade and re-upgrade --- config/scripts/pr-code-change-scope.mjs | 1 + config/scripts/pr-code-change-scope.test.mjs | 1 + ...-handle-replacement-downgrade.unit.test.ts | 136 +++++++++++++++++- 3 files changed, 135 insertions(+), 3 deletions(-) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 82d660a84ea3..f54c3fe74392 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -173,6 +173,7 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/shared/structured-agent-session-send-mutation.ts', 'src/shared/structured-agent-session-outbox.ts', 'src/shared/agent-session-record', + 'src/shared/agent-session-provider-handle', 'src/shared/agent-session-journal-', 'src/main/ai-vault/structured-session-ownership.ts', 'src/main/native-chat/agent-session-journal/', diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index ffd19687528a..9420a44b11c9 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -365,6 +365,7 @@ describe('per-job path classification', () => { 'src/shared/protocol-version.ts', 'src/shared/terminal-stream-protocol.ts', 'src/shared/agent-session-wire.ts', + 'src/shared/agent-session-provider-handle.ts', 'src/shared/agent-session-mutation-envelope.ts', 'src/shared/agent-session-journal-item-key.ts', 'src/shared/agent-session-journal-types.ts', diff --git a/tests/e2e/cross-version-wire/agent-session-handle-replacement-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-handle-replacement-downgrade.unit.test.ts index 66c51039375b..ee103350cdf0 100644 --- a/tests/e2e/cross-version-wire/agent-session-handle-replacement-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-handle-replacement-downgrade.unit.test.ts @@ -1,6 +1,12 @@ import { expect, test } from 'vitest' -import { agentSessionProviderHandleKey } from '../../../src/shared/agent-session-provider-handle-encoding' +import Database from '../../../src/main/sqlite/sync-database' import { + agentSessionProviderHandleKey, + claudeProviderHandle, + codexProviderHandle +} from '../../../src/shared/agent-session-provider-handle-encoding' +import { + decodePersistedAgentSessionProviderHandleChain, encodePersistedAgentSessionProviderHandleChain, type AgentSessionProviderHandle, type AgentSessionProviderHandleLink @@ -9,9 +15,10 @@ import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../src/shared/agent-session-record.test-fixture' +import { encodeAgentSessionRecord } from '../../../src/shared/agent-session-record-stored-form' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' -// The latest release: handles stored and held in their typed form. +// A release before neutral handles: handles stored and held in their typed form. const RELEASE_REF = 'v1.4.220' // The main build that made handles neutral; no release has it yet. Move to the first that does. const NEUTRAL_HANDLE_REF = 'e817b0e23747ffd6f16f2ddefea861950d82a3c0' @@ -69,10 +76,47 @@ const REPLACED = [ }) ] +function storeMaps(value: unknown) { + if ( + typeof value !== 'object' || + value === null || + !('records' in value) || + !(value.records instanceof Map) || + !('unreadableRecords' in value) || + !(value.unreadableRecords instanceof Map) + ) { + throw new Error('old store must expose its readable and unreadable rows') + } + return value +} + +function legacyRow(provider: 'claude' | 'codex') { + const fixture = agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId: `chat-${provider}` }) + ) + return encodeAgentSessionRecord({ + ...fixture, + provider, + accountHome: + provider === 'claude' + ? fixture.accountHome + : { variable: 'CODEX_HOME', path: '/home/user/.codex' }, + providerHandleChain: [ + { + ...fixture.providerHandleChain[0], + handle: + provider === 'claude' + ? claudeProviderHandle('saved-claude', 'leaf-1') + : codexProviderHandle('saved-codex') + } + ] + }) +} + // A replacement is stored as it is held, which older builds would refuse; it is safe only because // the rows that can hold one are rows they already set aside. Claude and Codex chains refuse it. test.each([RELEASE_REF, NEUTRAL_HANDLE_REF])( - '%s sets aside a chat a replacement can reach whether or not it holds one', + '%s preserves legacy rows and sets aside a replaced neutral row verbatim until re-upgrade', async (ref) => { const checkout = await materializeReleaseCheckout(ref) const records = await importReleaseCheckoutModule( @@ -85,6 +129,92 @@ test.each([RELEASE_REF, NEUTRAL_HANDLE_REF])( } expect(isRecord(neutralRow(REOPENED))).toBe(false) expect(isRecord(neutralRow(REPLACED))).toBe(false) + + const [rowModule, draftModule] = await Promise.all([ + importReleaseCheckoutModule(checkout, 'src/main/runtime/agent-session-record-rows.ts'), + importReleaseCheckoutModule(checkout, 'src/main/runtime/agent-session-store-draft.ts') + ]) + const loadRows = rowModule.loadAgentSessionStoreRows + const writeRows = rowModule.writeAgentSessionStoreRows + const draftState = draftModule.draftAgentSessionStoreState + const rowWrites = draftModule.agentSessionStoreDraftRowWrites + if ( + typeof loadRows !== 'function' || + typeof writeRows !== 'function' || + typeof draftState !== 'function' || + typeof rowWrites !== 'function' + ) { + throw new Error(`${ref} must expose the row load and write path`) + } + const db = new Database(':memory:') + try { + db.exec(` + CREATE TABLE agent_session_records (session_id TEXT PRIMARY KEY, record_json TEXT); + CREATE TABLE agent_session_operations (operation_key TEXT PRIMARY KEY, row_json TEXT); + CREATE TABLE agent_session_retired_claim_keys (key_id TEXT PRIMARY KEY, retired_at INTEGER); + CREATE TABLE agent_session_tabs (tab_id TEXT PRIMARY KEY, session_id TEXT, position INTEGER); + CREATE TABLE agent_session_store_meta (key TEXT PRIMARY KEY, value TEXT); + `) + const insert = db.prepare('INSERT INTO agent_session_records VALUES (?, ?)') + const legacyRows = [legacyRow('claude'), legacyRow('codex')] + for (const row of legacyRows) { + expect(isRecord(row)).toBe(true) + insert.run(row.sessionId, JSON.stringify(row)) + } + // Whitespace proves preservation of the stored bytes, not just the parsed value. + const neutralJson = JSON.stringify(neutralRow(REPLACED), null, 2) + insert.run('session-alpha-1', neutralJson) + const loaded = storeMaps(loadRows(db, 'local')) + expect([...loaded.records.keys()]).toEqual(['chat-claude', 'chat-codex']) + expect(loaded.unreadableRecords.get('session-alpha-1')).toEqual({ + reason: 'current_shape_invalid', + raw: JSON.parse(neutralJson) + }) + const draft = storeMaps(draftState(loaded)) + for (const row of legacyRows) { + const record: unknown = loaded.records.get(row.sessionId) + if (typeof record !== 'object' || record === null || !('lease' in record)) { + throw new Error(`${ref} must read the legacy row`) + } + const lease = record.lease + if (typeof lease !== 'object' || lease === null) { + throw new Error(`${ref} must preserve the legacy lease`) + } + // Re-derive the owner after load, forcing the old build's actual serialization path. + draft.records.set(row.sessionId, { ...record, lease: { ...lease, unreconciled: false } }) + } + const writes: unknown = rowWrites(loaded, draft) + expect(writes).not.toBeNull() + writeRows(db, writes) + for (const row of legacyRows) { + expect( + db + .prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?') + .get(row.sessionId)?.record_json + ).toBe(JSON.stringify(row)) + } + const preserved = db + .prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?') + .get('session-alpha-1')?.record_json + expect(preserved).toBe(neutralJson) + if (typeof preserved !== 'string') { + throw new Error('the neutral row must survive the old build') + } + const upgraded: unknown = JSON.parse(preserved) + if ( + typeof upgraded !== 'object' || + upgraded === null || + !('providerHandleChain' in upgraded) + ) { + throw new Error('the preserved row must still contain its chain') + } + // The provider-neutral record reader lands in the consumer PR; this PR owns chain decoding. + expect(decodePersistedAgentSessionProviderHandleChain(upgraded.providerHandleChain)).toEqual( + REPLACED + ) + } finally { + db.close() + } }, 300_000 ) From 503ebe4480d67d1d87b984512693fc3dfc11dbc9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:58:23 -0700 Subject: [PATCH 239/269] Require the ACP directory in the runtime import check --- config/scripts/check-runtime-electron-ratchet.mjs | 3 +-- config/scripts/check-runtime-electron-ratchet.test.mjs | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs index 3e04b8c46f25..5c6dce96b255 100644 --- a/config/scripts/check-runtime-electron-ratchet.mjs +++ b/config/scripts/check-runtime-electron-ratchet.mjs @@ -49,8 +49,7 @@ export const STRUCTURED_CHAT_LANES = [ { directory: ['src', 'shared'] }, { directory: ['src', 'main', 'runtime'], basename: /^(?:structured-|agent-session-)/ }, { directory: ['src', 'main', 'provider-process'] }, - // Allowed absent until it lands; every other lane throws if missing, so a rename can't empty it. - { directory: ['src', 'main', 'acp'], mayBeAbsent: true } + { directory: ['src', 'main', 'acp'] } ] export function collectStructuredChatEntryPoints(root = ROOT) { diff --git a/config/scripts/check-runtime-electron-ratchet.test.mjs b/config/scripts/check-runtime-electron-ratchet.test.mjs index a8985f0c014f..f759ec1d4345 100644 --- a/config/scripts/check-runtime-electron-ratchet.test.mjs +++ b/config/scripts/check-runtime-electron-ratchet.test.mjs @@ -32,13 +32,13 @@ describe('structured chat coverage', () => { return root } - // Every lane that must exist; acp/ may be absent until it lands. const requiredLanes = { 'src/main/native-chat/reader.ts': 'export {}', 'src/main/claude/claude-session.ts': 'export {}', 'src/main/codex/codex-session.ts': 'export {}', 'src/main/runtime/structured-agent-session-host.ts': 'export {}', 'src/main/provider-process/provider-process-teardown.ts': 'export {}', + 'src/main/acp/acp-session-runtime.ts': 'export {}', 'src/shared/agent-session-record.ts': 'export {}' } @@ -90,7 +90,7 @@ describe('structured chat coverage', () => { Object.entries(requiredLanes).filter(([file]) => !file.startsWith(`${lane}/`)) ) expect(() => collectStructuredChatEntryPoints(fixture(without))).toThrow(`${lane} is missing`) - expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(6) + expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(7) } ) @@ -129,7 +129,6 @@ describe('the default entry points', () => { } }) - // Retires the temporary flag: the PR that adds acp/ must make it required. it('lets only directories that have not landed yet be absent', () => { for (const lane of STRUCTURED_CHAT_LANES.filter((candidate) => candidate.mayBeAbsent)) { expect( From 754fe5ba8ef132aa2cd881f14b27dfdfbe7a3c6e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:53:16 -0700 Subject: [PATCH 240/269] test(ratchet): require src/main/acp now that this PR lands it --- .../check-runtime-electron-ratchet.mjs | 6 +----- .../check-runtime-electron-ratchet.test.mjs | 20 +++++-------------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs index 3e04b8c46f25..71a8e3a8aef7 100644 --- a/config/scripts/check-runtime-electron-ratchet.mjs +++ b/config/scripts/check-runtime-electron-ratchet.mjs @@ -49,17 +49,13 @@ export const STRUCTURED_CHAT_LANES = [ { directory: ['src', 'shared'] }, { directory: ['src', 'main', 'runtime'], basename: /^(?:structured-|agent-session-)/ }, { directory: ['src', 'main', 'provider-process'] }, - // Allowed absent until it lands; every other lane throws if missing, so a rename can't empty it. - { directory: ['src', 'main', 'acp'], mayBeAbsent: true } + { directory: ['src', 'main', 'acp'] } ] export function collectStructuredChatEntryPoints(root = ROOT) { return STRUCTURED_CHAT_LANES.flatMap((lane) => { const directory = path.join(root, ...lane.directory) if (!existsSync(directory)) { - if (lane.mayBeAbsent) { - return [] - } throw new Error( `[runtime-electron-ratchet] ${lane.directory.join('/')} is missing. If it moved, update STRUCTURED_CHAT_LANES; otherwise the gate would silently check nothing there.` ) diff --git a/config/scripts/check-runtime-electron-ratchet.test.mjs b/config/scripts/check-runtime-electron-ratchet.test.mjs index a8985f0c014f..2e46362390eb 100644 --- a/config/scripts/check-runtime-electron-ratchet.test.mjs +++ b/config/scripts/check-runtime-electron-ratchet.test.mjs @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import process from 'node:process' @@ -9,8 +9,7 @@ import { defaultEntryPoints, diffAgainstBaseline, main, - readBaseline, - STRUCTURED_CHAT_LANES + readBaseline } from './check-runtime-electron-ratchet.mjs' describe('structured chat coverage', () => { @@ -32,13 +31,14 @@ describe('structured chat coverage', () => { return root } - // Every lane that must exist; acp/ may be absent until it lands. + // Every lane that must exist. const requiredLanes = { 'src/main/native-chat/reader.ts': 'export {}', 'src/main/claude/claude-session.ts': 'export {}', 'src/main/codex/codex-session.ts': 'export {}', 'src/main/runtime/structured-agent-session-host.ts': 'export {}', 'src/main/provider-process/provider-process-teardown.ts': 'export {}', + 'src/main/acp/acp-structured-session-adapter.ts': 'export {}', 'src/shared/agent-session-record.ts': 'export {}' } @@ -90,7 +90,7 @@ describe('structured chat coverage', () => { Object.entries(requiredLanes).filter(([file]) => !file.startsWith(`${lane}/`)) ) expect(() => collectStructuredChatEntryPoints(fixture(without))).toThrow(`${lane} is missing`) - expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(6) + expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(7) } ) @@ -128,16 +128,6 @@ describe('the default entry points', () => { expect(entries.some((file) => file.startsWith(lane))).toBe(true) } }) - - // Retires the temporary flag: the PR that adds acp/ must make it required. - it('lets only directories that have not landed yet be absent', () => { - for (const lane of STRUCTURED_CHAT_LANES.filter((candidate) => candidate.mayBeAbsent)) { - expect( - existsSync(path.join(process.cwd(), ...lane.directory)), - lane.directory.join('/') - ).toBe(false) - } - }) }) // Why `main`: it is what `pnpm lint` and CI run, so these fail if its entry list drops the lanes. From b6f57c30b06d3e5a35c24ba85a7ecf68265b33fb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:49:29 -0700 Subject: [PATCH 241/269] feat(acp): a saved session the agent cannot reopen continues in a new one, with one warning row When session/load (or session/resume) of a saved ACP session fails, the chat starts a new session and records it as a creation that replaces the lost one (#25747's 'replaces' link), and writes one warning row that the agent no longer remembers the earlier messages. A created session the agent reports missing is still superseded silently; a signed-out agent or a start that is over (Close, Stop, a lost agent) still fails the start. --- src/main/acp/acp-session-reopen-failure.ts | 57 +++++++ src/main/acp/acp-structured-acquire.ts | 32 ++-- ...acp-structured-host-restore-failed.test.ts | 141 ++++++++++++++++++ .../acp/acp-structured-host.test-support.ts | 18 ++- .../acp-structured-launch-resolution.test.ts | 17 ++- .../acp/acp-structured-launch-resolution.ts | 10 +- ...-structured-session-adapter-resume.test.ts | 61 +++++++- .../acp-structured-session-adapter.test.ts | 2 +- .../agent-session-failure-words-text.ts | 6 + .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/fr.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- src/shared/agent-session-failure-copy.ts | 4 +- src/shared/agent-session-failure-words.ts | 4 +- src/shared/agent-session-failure.ts | 7 +- 19 files changed, 342 insertions(+), 36 deletions(-) create mode 100644 src/main/acp/acp-session-reopen-failure.ts create mode 100644 src/main/acp/acp-structured-host-restore-failed.test.ts diff --git a/src/main/acp/acp-session-reopen-failure.ts b/src/main/acp/acp-session-reopen-failure.ts new file mode 100644 index 000000000000..dd990e3c9289 --- /dev/null +++ b/src/main/acp/acp-session-reopen-failure.ts @@ -0,0 +1,57 @@ +// What takes over when an ACP agent cannot reopen the session a chat proved. A session this chat +// created and the agent never saved is superseded: there was nothing to remember. Any other is +// replaced by a new session that names it, and the chat says once that the agent forgot. + +import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { AcpAuthRequiredError, AcpRpcError } from './acp-errors' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' + +/** ACP's "resource not found": the agent holds no session under the id this chat proved. */ +const ACP_RESOURCE_NOT_FOUND = -32002 +export const ACP_REOPEN_FAILED = 'ACP agent could not reopen its saved session; starting a new one' + +/** The fields the new session's link carries; rethrows `error` when the start must fail instead. */ +export function acpReopenTakeover( + error: unknown, + resume: NonNullable, + start: { over: boolean; now: number; warn: (fields: { scope: string; error: unknown }) => void } +): Pick { + // Signed out, or the start is over (Close, Stop, a lost agent): a new session would not help. + if (error instanceof AcpAuthRequiredError || start.over) { + throw error + } + if ( + error instanceof AcpRpcError && + error.code === ACP_RESOURCE_NOT_FOUND && + resume.mayBeUnsaved + ) { + return { supersedesKey: resume.key } + } + start.warn({ scope: 'acp-restore-failed', error }) + return { replaces: { key: resume.key, reason: 'restore-failed', replacedAt: start.now } } +} + +/** The one row a replaced session writes: the agent no longer remembers what came before. */ +export function acpSessionNotRestoredRow( + providerSessionId: string, + agentName: string +): ProviderTimelineEvent[] { + return [ + { + type: 'item.update', + item: `session-not-restored:${providerSessionId}`, + body: { + kind: 'status', + tone: 'warning', + ...agentSessionFailureWords(agentSessionFailureFact('sessionNotRestored'), { + surface: 'row', + agentName + }) + }, + join: { thread: providerSessionId } + } + ] +} diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 04edf57d871a..5df093b05ff8 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -1,7 +1,7 @@ // Making a reservation real for an ACP agent: spawn the child, record it before any handshake, // initialize with the client's file system and terminals off, then reattach the session this chat // proved with `session/load` (`session/resume` only for an agent that cannot load) or start a new -// one. The journal already holds a reattached chat, so whatever the agent sends while it reattaches +// one; a saved session the agent cannot reopen is replaced by a new one, with a warning row. The journal already holds a reattached chat, so whatever the agent sends while it reattaches // is not written, except context usage. The handshake has no time bound: the acquire's abort signal // (Close, Stop, quit) stops it at any point. @@ -20,8 +20,13 @@ import { PROVIDER_SPAWN_TOKEN_ENV } from '../provider-process/provider-spawned-process-identity' import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' -import { AcpAuthRequiredError, AcpRpcError } from './acp-errors' +import { AcpAuthRequiredError } from './acp-errors' import { ACP_CHILD_ENV_TO_DELETE } from './acp-launch-specs' +import { + ACP_REOPEN_FAILED, + acpReopenTakeover, + acpSessionNotRestoredRow +} from './acp-session-reopen-failure' import { AcpSessionRuntime, type AcpSessionEvent } from './acp-session-runtime' import type { AcpStructuredChild } from './acp-structured-child' import { ACP_HANDLE_TRANSPORT } from './acp-structured-agent-definitions' @@ -41,8 +46,6 @@ import { import { AcpStructuredTurns, type AcpStructuredTurnsDeps } from './acp-structured-turns' import { RequestPermissionResponseSchema } from './generated/acp-protocol.generated' -/** ACP's "resource not found": the agent holds no session under the id this chat proved. */ -const ACP_RESOURCE_NOT_FOUND = -32002 /** Frames an agent may send before its session exists; past this they are dropped. */ const MAX_EARLY_FRAMES = 2_048 export function acpAgentName(agent: string): string { @@ -225,7 +228,7 @@ export async function acquireAcpStructuredSession(input: { const resume = launch.resume let started: Awaited> | null = null let liveLane: AcpStructuredLane | null = null - let supersedesKey: string | undefined + let takeover: ReturnType | null = null if (resume) { const attaching = makeLane(resume.sessionId, true) liveLane = attaching @@ -238,13 +241,13 @@ export async function acquireAcpStructuredSession(input: { slot.reattaching = false attaching.translator.finishLoad() } catch (error) { - const notFound = error instanceof AcpRpcError && error.code === ACP_RESOURCE_NOT_FOUND - if (!notFound || resume.replaceableKey === null) { - throw error - } - // A session this chat created and the agent never saved: a new one takes its place, with a - // new lane, so nothing of the failed attach's window outlives it. - supersedesKey = resume.replaceableKey + takeover = acpReopenTakeover(error, resume, { + over: connection.closed || acquire.signal?.aborted === true, + now: now(), + warn: (fields) => deps.logger?.warn(ACP_REOPEN_FAILED, { ...fields, sessionId }) + }) + // A new session takes the old one's place, with a new lane, so nothing of the failed + // attach's window outlives it. } } if (!started || !liveLane) { @@ -264,7 +267,7 @@ export async function acquireAcpStructuredSession(input: { origin: started.kind === 'new' ? 'created' : 'resumed', mintedAtFence: acquire.fence, observedAt: now(), - ...(supersedesKey ? { supersedesKey } : {}) + ...takeover } session = { sessionId, @@ -303,6 +306,9 @@ export async function acquireAcpStructuredSession(input: { if (child.exited || connection.closed) { throw new Error(child.stderrTail() || `${spec.command} exited while starting`) } + if (takeover?.replaces) { + liveLane.apply(acpSessionNotRestoredRow(started.sessionId, agentName)) + } return { acquisition: { process, link, acquisitionGeneration: generation }, session } } catch (error) { session = null diff --git a/src/main/acp/acp-structured-host-restore-failed.test.ts b/src/main/acp/acp-structured-host-restore-failed.test.ts new file mode 100644 index 000000000000..19ede7dace52 --- /dev/null +++ b/src/main/acp/acp-structured-host-restore-failed.test.ts @@ -0,0 +1,141 @@ +// A saved Grok session Grok cannot reopen, through the real host, record store and launch +// resolver: a fresh session continues the chat, the chain records what it replaced, and the chat +// says so once. + +import { afterEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { + agentSessionProviderHandleChainHead, + agentSessionProviderHandleKey +} from '../../shared/agent-session-provider-handle' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { GROK, GROK_CONFIG_OPTIONS, PROVIDER_SESSION } from './acp-structured-adapter.test-support' +import { ACP_HANDLE_TRANSPORT } from './acp-structured-agent-definitions' +import { createAcpStructuredLaunchResolver } from './acp-structured-launch-resolution' +import { + attachParams, + openHostRig, + promptIdOf, + RESUMES, + send +} from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const FRESH = 'grok-fresh-session' +const keyOf = (nativeId: string) => + agentSessionProviderHandleKey({ transport: ACP_HANDLE_TRANSPORT, agent: 'grok', nativeId }) + +/** Grok's first session opens, then cannot be reopened; every session after it reopens. */ +function firstSessionLost(loads: string[]) { + let opened = 0 + return (agent: AcpScriptedAgent) => { + agent.on('session/new', (frame) => { + opened += 1 + agent.reply(frame, { + sessionId: opened === 1 ? PROVIDER_SESSION : FRESH, + configOptions: GROK_CONFIG_OPTIONS + }) + }) + agent.on('session/load', (frame) => { + const { sessionId } = z.object({ sessionId: z.string() }).parse(frame.params) + loads.push(sessionId) + if (sessionId === PROVIDER_SESSION) { + agent.fail(frame, -32603, 'session file is corrupt') + } else { + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + } + }) + } +} + +describe('a saved Grok session Grok cannot reopen', () => { + it('continues the chat in a fresh session that the chain records and the next reopen loads', async () => { + const loads: string[] = [] + const resolver: { resolve: ReturnType | null } = { + resolve: null + } + const { rig, host, store, fence } = await openHostRig({ + initialize: RESUMES, + script: firstSessionLost(loads), + deps: { + resolveLaunch: (input) => { + if (!resolver.resolve) { + throw new Error('launch resolver not ready') + } + return resolver.resolve(input) + } + } + }) + resolver.resolve = createAcpStructuredLaunchResolver(GROK, { + store, + resolveWorkspacePath: async () => '/workspace', + resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + resolveCommand: () => '/fake/grok' + }) + const warnings = async () => { + await host.flushStreamedEvents(SESSION) + return (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items.filter( + (row) => + row.body.kind === 'status' && + readAgentSessionFailureFact(row.body.failure)?.kind === 'sessionNotRestored' + ) + } + + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + + expect(loads).toEqual([PROVIDER_SESSION]) + const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] + expect(chain).toHaveLength(2) + expect(agentSessionProviderHandleChainHead(chain)).toMatchObject({ + origin: 'created', + handle: { nativeId: FRESH }, + replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' } + }) + const rows = await warnings() + expect(rows).toHaveLength(1) + expect(rows[0]?.body).toMatchObject({ + kind: 'status', + tone: 'warning', + text: "Grok couldn't reopen its earlier session, so this chat continues in a new one. Grok doesn't remember the earlier messages." + }) + + // The chat keeps working in the fresh session. + await send(host, 'still there?') + const prompt = await rig.frame('session/prompt') + expect(prompt.params).toMatchObject({ sessionId: FRESH }) + rig.child().agent.notify('session/update', { + sessionId: FRESH, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'yes' } }, + _meta: { promptId: promptIdOf(prompt) } + }) + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + await rig.settle() + await host.flushStreamedEvents(SESSION) + const messages = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + .filter((row) => row.body.kind === 'message') + .map((row) => (row.body.kind === 'message' ? row.body.blocks : [])) + expect(messages.flat()).toEqual( + expect.arrayContaining([ + { type: 'text', text: 'still there?' }, + { type: 'text', text: 'yes' } + ]) + ) + + // The next reopen loads the fresh session, and says nothing new. + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + expect(store.getRecord(SESSION)?.providerHandleChain).toHaveLength(3) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-host.test-support.ts b/src/main/acp/acp-structured-host.test-support.ts index c021f4247a48..c81c8c884e3a 100644 --- a/src/main/acp/acp-structured-host.test-support.ts +++ b/src/main/acp/acp-structured-host.test-support.ts @@ -4,6 +4,7 @@ import { expect } from 'vitest' import { z } from 'zod' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { agentSessionProviderHandleKey } from '../../shared/agent-session-provider-handle' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { agentSessionStoredAgents } from '../../shared/agent-session-stored-agent' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' @@ -32,7 +33,10 @@ import { replyChunk, type FakeAcpChild } from './acp-structured-adapter.test-support' -import { acpStructuredAgentDefinition } from './acp-structured-agent-definitions' +import { + ACP_HANDLE_TRANSPORT, + acpStructuredAgentDefinition +} from './acp-structured-agent-definitions' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' @@ -59,7 +63,17 @@ export function launch(resume: () => boolean): () => Promise { expect(launch.args).toContain('--always-approve') }) - it('resumes the chain head, replaceable only when this chat created it', async () => { + it('resumes the chain head by its key, possibly unsaved only when this chat created it', async () => { const link = (origin: 'created' | 'resumed') => ({ linkId: `link-${origin}`, origin, @@ -76,12 +80,17 @@ describe('ACP launch resolution', () => { const created = await resolver(grokRecord({ providerHandleChain: [link('created')] })).resolve({ identity }) - expect(created.resume).toMatchObject({ sessionId: 'acp-1' }) - expect(created.resume?.replaceableKey).toEqual(expect.any(String)) + const handle: AgentSessionProviderHandle = { + transport: 'acp', + agent: 'grok', + nativeId: 'acp-1' + } + const key = agentSessionProviderHandleKey(handle) + expect(created.resume).toEqual({ sessionId: 'acp-1', key, mayBeUnsaved: true }) const resumed = await resolver( grokRecord({ providerHandleChain: [link('created'), link('resumed')] }) ).resolve({ identity }) - expect(resumed.resume).toEqual({ sessionId: 'acp-1', replaceableKey: null }) + expect(resumed.resume).toEqual({ sessionId: 'acp-1', key, mayBeUnsaved: false }) }) it('refuses a record pinned to another host or another agent', async () => { diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index 870ba68803ef..af2747803ffb 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -26,8 +26,10 @@ export type AcpStructuredLaunch = { /** The provider session to load; null starts a new one. */ resume: { sessionId: string - /** Only a session this chat created may be one the agent never saved, and so be replaced. */ - replaceableKey: string | null + /** The chain key of the session to load, which a fresh session names if it takes over. */ + key: string + /** Only a session this chat created may be one the agent never saved, and so be superseded. */ + mayBeUnsaved: boolean } | null } @@ -95,8 +97,8 @@ export function createAcpStructuredLaunchResolver( resume: head ? { sessionId: head.handle.nativeId, - replaceableKey: - head.origin === 'created' ? agentSessionProviderHandleKey(head.handle) : null + key: agentSessionProviderHandleKey(head.handle), + mayBeUnsaved: head.origin === 'created' } : null } diff --git a/src/main/acp/acp-structured-session-adapter-resume.test.ts b/src/main/acp/acp-structured-session-adapter-resume.test.ts index a0ab62472bc8..9a28308f0e0c 100644 --- a/src/main/acp/acp-structured-session-adapter-resume.test.ts +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -1,11 +1,13 @@ import { afterEach, describe, expect, it } from 'vitest' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { closeProviderTimelineRigs, messageText, SESSION } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' @@ -30,7 +32,9 @@ const hello: AgentJournalMessageItem = { /** Grok's own capabilities: it loads and resumes sessions; Orca reopens with `session/load`. */ const RESUMES = { agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } } } -const resume = { launch: { resume: { sessionId: PROVIDER_SESSION, replaceableKey: null } } } +const resume = { + launch: { resume: { sessionId: PROVIDER_SESSION, key: 'acp:grok:saved', mayBeUnsaved: false } } +} /** Everything Grok may send while it reattaches: its saved exchange marked as replay, a task the * dead process left running ended by the restart, and its context usage. */ @@ -71,6 +75,13 @@ async function exchange(rig: AcpAdapterRig, reply: string, end: boolean): Promis const texts = async (rig: AcpAdapterRig) => (await rig.rig.rows()).flatMap((row) => messageText(row.body) ?? []) +const notRestoredRows = async (rig: AcpAdapterRig) => + (await rig.rig.rows()).filter( + (row) => + row.body.kind === 'status' && + readAgentSessionFailureFact(row.body.failure)?.kind === 'sessionNotRestored' + ) + /** What Grok might send while it resumes with no replay mark: an old reply and its turn's end. */ function sendsUnmarkedWhileAttaching(agent: AcpScriptedAgent): void { agent.notify('session/update', replyChunk('prompt:old', 'stale text')) @@ -265,7 +276,9 @@ describe('reattaching a Grok chat the journal holds', () => { it('starts a new session in place of a created one that session/load reports missing', async () => { const rig = await openAcpAdapterRig({ - launch: { resume: { sessionId: 'never-saved', replaceableKey: 'acp:grok:never-saved' } }, + launch: { + resume: { sessionId: 'never-saved', key: 'acp:grok:never-saved', mayBeUnsaved: true } + }, initialize: RESUMES, script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Resource not found')) @@ -279,6 +292,50 @@ describe('reattaching a Grok chat the journal holds', () => { // The failed load left the translator taking prompts. await exchange(rig, 'hi', true) expect(await texts(rig)).toEqual(['hello', 'hi']) + // Nothing was forgotten: the agent never saved that session. + expect(await notRestoredRows(rig)).toEqual([]) + }) + + it.each([ + ['a saved session', false, -32603, 'session file is corrupt'], + ['a created session that fails for another reason', true, -32603, 'session file is corrupt'], + ['a saved session the agent reports missing', false, -32002, 'Resource not found'] + ])( + 'continues %s it cannot reopen in a new one, says so once, and keeps working', + async (_label, mayBeUnsaved, code, message) => { + const rig = await openAcpAdapterRig({ + launch: { resume: { sessionId: 'saved-1', key: 'acp-key-saved-1', mayBeUnsaved } }, + initialize: RESUMES, + script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, code, message)) + }) + const acquired = await rig.acquire() + expect(acquired.link).toMatchObject({ + origin: 'created', + handle: { nativeId: PROVIDER_SESSION }, + replaces: { key: 'acp-key-saved-1', reason: 'restore-failed', replacedAt: 5_000 } + }) + expect(acquired.link.supersedesKey).toBeUndefined() + expect(rig.sent('session/new')).toHaveLength(1) + const rows = await notRestoredRows(rig) + expect(rows).toHaveLength(1) + expect(rows[0]?.body).toMatchObject({ kind: 'status', tone: 'warning' }) + await exchange(rig, 'hi', true) + expect(await texts(rig)).toEqual(['hello', 'hi']) + expect(await notRestoredRows(rig)).toHaveLength(1) + } + ) + + it('refuses a signed-out agent at reopen rather than replacing its session', async () => { + const rig = await openAcpAdapterRig({ + launch: { resume: { sessionId: 'saved-1', key: 'acp-key-saved-1', mayBeUnsaved: false } }, + initialize: RESUMES, + script: (agent) => + agent.on('session/load', (frame) => agent.fail(frame, -32000, 'Authentication required')) + }) + const failure = await rig.acquire().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AgentSessionAcquisitionRefusal) + expect(failure).toMatchObject({ reason: 'notSignedIn' }) + expect(rig.sent('session/new')).toEqual([]) }) it('keeps the slash commands Grok reports while it loads', async () => { diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index a18181bdbbbc..457ca07cd4ab 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -79,7 +79,7 @@ describe('ACP structured session adapter: acquire', () => { it('replaces a created session the agent never saved with a new one', async () => { const rig = await openAcpAdapterRig({ - launch: { resume: { sessionId: 'lost', replaceableKey: 'old-key' } }, + launch: { resume: { sessionId: 'lost', key: 'old-key', mayBeUnsaved: true } }, script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Session not found')) }) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 59c4947c8b4f..b5cd55b091e4 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -238,6 +238,12 @@ const PIECES: Record + translate( + 'components.native-chat.failureWords.sessionNotRestored', + COPY.sessionNotRestored, + values ) } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index b1f9290f5a0e..124c33a5a601 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2794,6 +2794,7 @@ "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.", "notSignedIn": "{{agent}} is not signed in for the selected account.", "previousExitUnverifiable": "Couldn't stop {{agent}} from before.", + "sessionNotRestored": "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages.", "providerExitedRejection": "{{agent}} stopped before this message was sent.", "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", "providerRateLimited": "{{agent}} is rate-limited and retrying.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6a8e3dbfea99..a6146de51eeb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17788,7 +17788,8 @@ "providerRateLimited": "{{agent}} is rate-limited and retrying.", "providerRetrying": "{{agent}} hit a temporary problem and is retrying.", "providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.", - "previousExitUnverifiable": "Couldn't stop {{agent}} from before." + "previousExitUnverifiable": "Couldn't stop {{agent}} from before.", + "sessionNotRestored": "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages." }, "writeNotice": { "notDoneReadHistory": "This chat's history couldn't be loaded.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9de5fb740ec8..821571b11123 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17575,7 +17575,8 @@ "providerRateLimited": "{{agent}} alcanzó un límite de solicitudes y está reintentando.", "providerRetrying": "{{agent}} tuvo un problema temporal y está reintentando.", "providerRetryingQuoted": "{{agent}} está reintentando: {{detail}}.", - "previousExitUnverifiable": "No se pudo detener la ejecución anterior de {{agent}}." + "previousExitUnverifiable": "No se pudo detener la ejecución anterior de {{agent}}.", + "sessionNotRestored": "{{agent}} no pudo reabrir su sesión anterior, así que este chat continúa en una nueva. {{agent}} no recuerda los mensajes anteriores." }, "writeNotice": { "notDoneReadHistory": "No se pudo cargar el historial de este chat.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 5495da419d23..2b51ffb316ef 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17674,7 +17674,8 @@ "providerRateLimited": "{{agent}} est limité en débit et réessaie.", "providerRetrying": "{{agent}} a rencontré un problème temporaire et réessaie.", "providerRetryingQuoted": "{{agent}} réessaie : {{detail}}.", - "previousExitUnverifiable": "Impossible d'arrêter l'exécution précédente de {{agent}}." + "previousExitUnverifiable": "Impossible d'arrêter l'exécution précédente de {{agent}}.", + "sessionNotRestored": "{{agent}} n'a pas pu rouvrir sa session précédente, cette discussion continue donc dans une nouvelle. {{agent}} ne se souvient pas des messages précédents." }, "writeNotice": { "notDoneReadHistory": "L'historique de ce chat n'a pas pu être chargé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index fa9c9b015099..bf35068182fd 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17610,7 +17610,8 @@ "providerRateLimited": "{{agent}} はレート制限を受けているため、再試行しています。", "providerRetrying": "{{agent}} で一時的な問題が発生したため、再試行しています。", "providerRetryingQuoted": "{{agent}} は再試行しています: {{detail}}。", - "previousExitUnverifiable": "以前の {{agent}} を停止できませんでした。" + "previousExitUnverifiable": "以前の {{agent}} を停止できませんでした。", + "sessionNotRestored": "{{agent}} は以前のセッションを再開できなかったため、このチャットは新しいセッションで続行します。{{agent}} は以前のメッセージを覚えていません。" }, "writeNotice": { "notDoneReadHistory": "このチャットの履歴を読み込めませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 877675cdd0ec..d301c67cd658 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17610,7 +17610,8 @@ "providerRateLimited": "{{agent}}이(가) 속도 제한에 걸려 다시 시도하고 있습니다.", "providerRetrying": "{{agent}}에 일시적인 문제가 발생하여 다시 시도하고 있습니다.", "providerRetryingQuoted": "{{agent}}이(가) 다시 시도하고 있습니다: {{detail}}.", - "previousExitUnverifiable": "이전에 실행된 {{agent}}을(를) 중지하지 못했습니다." + "previousExitUnverifiable": "이전에 실행된 {{agent}}을(를) 중지하지 못했습니다.", + "sessionNotRestored": "{{agent}}이(가) 이전 세션을 다시 열 수 없어 이 채팅은 새 세션에서 계속됩니다. {{agent}}은(는) 이전 메시지를 기억하지 않습니다." }, "writeNotice": { "notDoneReadHistory": "이 채팅의 기록을 불러오지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d2d4ea49d1d7..b287b283701d 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17575,7 +17575,8 @@ "providerRateLimited": "{{agent}} 已被限流,正在重试。", "providerRetrying": "{{agent}} 遇到临时问题,正在重试。", "providerRetryingQuoted": "{{agent}} 正在重试:{{detail}}。", - "previousExitUnverifiable": "无法停止之前的 {{agent}}。" + "previousExitUnverifiable": "无法停止之前的 {{agent}}。", + "sessionNotRestored": "{{agent}} 无法重新打开之前的会话,因此此聊天将在新会话中继续。{{agent}} 不记得之前的消息。" }, "writeNotice": { "notDoneReadHistory": "无法加载此聊天的历史记录。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 1e555c676d64..bbd9c85ac3a7 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -82,7 +82,9 @@ export const AGENT_SESSION_FAILURE_COPY = { providerRateLimited: '{{agent}} is rate-limited and retrying.', providerRetrying: '{{agent}} hit a temporary problem and is retrying.', providerRetryingQuoted: '{{agent}} is retrying: {{detail}}.', - previousExitUnverifiable: "Couldn't stop {{agent}} from before." + previousExitUnverifiable: "Couldn't stop {{agent}} from before.", + sessionNotRestored: + "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages." } as const export type AgentSessionFailureCopyId = keyof typeof AGENT_SESSION_FAILURE_COPY diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 74e6e543b533..b217d2d9c262 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -277,7 +277,9 @@ const FAILURE_SENTENCES = { retry?.cause ), previousExitUnverifiable: (context, _fact, _surface, say) => - say('previousExitUnverifiable', agent(say, context)) + say('previousExitUnverifiable', agent(say, context)), + sessionNotRestored: (context, _fact, _surface, say) => + say('sessionNotRestored', agent(say, context)) } satisfies Record /** The sentence a person reads for this fact on this surface; never a marker. */ diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index a3c97850a1ea..5e6c1f81b133 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -49,7 +49,9 @@ export const AGENT_SESSION_FAILURE_KINDS = [ /** The provider is retrying a request its API refused; not a failure yet. */ 'providerRetrying', /** A child a Stop could not prove gone: its exit is unverifiable. Kept for rows hosts wrote. */ - 'previousExitUnverifiable' + 'previousExitUnverifiable', + /** The agent could not reopen the chat's saved session, so a fresh one without its memory continues it. */ + 'sessionNotRestored' ] as const export type AgentSessionFailureKind = (typeof AGENT_SESSION_FAILURE_KINDS)[number] @@ -65,7 +67,8 @@ const STATUS_ROW_ONLY_FAILURE_KINDS = [ 'stopRefused', 'answerUnconfirmed', 'providerRetrying', - 'previousExitUnverifiable' + 'previousExitUnverifiable', + 'sessionNotRestored' ] as const satisfies readonly AgentSessionFailureKind[] /** Why a message was not sent. A new failure kind is one of these until listed above. */ From dbeef457b8135dfff5e38f6f31bf6474e11b5267 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:07:49 -0700 Subject: [PATCH 242/269] chore(acp): rewrap the acquire header comment --- src/main/acp/acp-structured-acquire.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 5df093b05ff8..062eb6960e12 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -1,8 +1,9 @@ // Making a reservation real for an ACP agent: spawn the child, record it before any handshake, // initialize with the client's file system and terminals off, then reattach the session this chat // proved with `session/load` (`session/resume` only for an agent that cannot load) or start a new -// one; a saved session the agent cannot reopen is replaced by a new one, with a warning row. The journal already holds a reattached chat, so whatever the agent sends while it reattaches -// is not written, except context usage. The handshake has no time bound: the acquire's abort signal +// one; a saved session the agent cannot reopen is replaced by a new one, with a warning row. The +// journal already holds a reattached chat, so whatever the agent sends while it reattaches is not +// written, except context usage. The handshake has no time bound: the acquire's abort signal // (Close, Stop, quit) stops it at any point. import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' From bcff300eeb85cdaa443dae419514b63105034952 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:12:53 -0700 Subject: [PATCH 243/269] test(acp): a start closed while the agent reopens fails without opening or announcing a new session --- ...-structured-session-adapter-resume.test.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/main/acp/acp-structured-session-adapter-resume.test.ts b/src/main/acp/acp-structured-session-adapter-resume.test.ts index 9a28308f0e0c..abbbdd3b5529 100644 --- a/src/main/acp/acp-structured-session-adapter-resume.test.ts +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' @@ -11,6 +11,7 @@ import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wir import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { ACP_REOPEN_FAILED } from './acp-session-reopen-failure' import { GROK_CONFIG_OPTIONS, openAcpAdapterRig, @@ -338,6 +339,28 @@ describe('reattaching a Grok chat the journal holds', () => { expect(rig.sent('session/new')).toEqual([]) }) + it('fails a start closed while the agent reopens rather than opening a new session', async () => { + const start = new AbortController() + const logger = { warn: vi.fn(), error: vi.fn() } + const rig = await openAcpAdapterRig({ + launch: { resume: { sessionId: 'saved-1', key: 'acp-key-saved-1', mayBeUnsaved: false } }, + initialize: RESUMES, + deps: { logger }, + script: (agent) => + agent.on('session/load', (frame) => { + start.abort() + agent.fail(frame, -32603, 'session file is corrupt') + }) + }) + expect( + await rig.acquire({ signal: start.signal }).catch((error: unknown) => error) + ).toBeInstanceOf(Error) + expect(rig.sent('session/new')).toEqual([]) + expect(await notRestoredRows(rig)).toEqual([]) + // Nothing was replaced, so nothing says it was. + expect(logger.warn).not.toHaveBeenCalledWith(ACP_REOPEN_FAILED, expect.anything()) + }) + it('keeps the slash commands Grok reports while it loads', async () => { const rig = await openAcpAdapterRig({ ...resume, From 798fc3b49ec133c90213ddd7093edcbaf30be69e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:21:54 -0700 Subject: [PATCH 244/269] Let ACP connections own their supervised agent process --- src/main/acp/acp-agent-connection.test.ts | 238 +++++++++++++++++++ src/main/acp/acp-agent-connection.ts | 136 +++++++++++ src/main/acp/acp-json-rpc-peer.ts | 47 ++-- src/main/acp/acp-peer-limits.ts | 21 ++ src/main/acp/acp-permission-requests.test.ts | 25 +- src/main/acp/acp-prompt-cancel.ts | 94 -------- src/main/acp/acp-session-lifecycle.test.ts | 45 ++-- src/main/acp/acp-session-runtime.test.ts | 148 +++--------- src/main/acp/acp-session-runtime.ts | 73 ++---- 9 files changed, 511 insertions(+), 316 deletions(-) create mode 100644 src/main/acp/acp-agent-connection.test.ts create mode 100644 src/main/acp/acp-agent-connection.ts delete mode 100644 src/main/acp/acp-prompt-cancel.ts diff --git a/src/main/acp/acp-agent-connection.test.ts b/src/main/acp/acp-agent-connection.test.ts new file mode 100644 index 000000000000..c4a7edeaee8c --- /dev/null +++ b/src/main/acp/acp-agent-connection.test.ts @@ -0,0 +1,238 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../shared/child-process/run-process' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' +import { ROOT_ONLY_GRACEFUL_EXIT_MS } from '../provider-process/provider-process-close' +import { + createAcpAgentConnection, + type AcpAgentConnection, + type AcpAgentConnectionOptions +} from './acp-agent-connection' +import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' + +const teardown = vi.hoisted(() => vi.fn(async () => 'unverifiable' as const)) +vi.mock('../provider-process/provider-process-teardown', () => ({ + terminateProviderProcessTree: teardown +})) + +const opened: { connection: AcpAgentConnection; exit: () => void }[] = [] +const grace = + process.platform === 'win32' ? ROOT_ONLY_GRACEFUL_EXIT_MS : PROVIDER_SUPERVISOR_MAX_STOP_MS +const start = { cwd: '/execution-host/folder', mcpServers: [] } +const prompt = [{ type: 'text', text: 'hello' }] as const + +function fixture( + options: AcpAgentConnectionOptions = {}, + behavior: { pid?: number | null; exitOnEnd?: boolean } = {} +) { + const agent = new AcpScriptedAgent() + const child = Object.assign(new EventEmitter(), { + pid: behavior.pid === null ? undefined : (behavior.pid ?? 9_999_999), + stdout: agent.stdout, + stdin: agent.stdin, + stderr: new PassThrough(), + kill: vi.fn(() => true) + }) + const spawn = vi.fn(() => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The supervised connection reads events, pid, piped stdio and kill; this fixture supplies each. + return child as unknown as ReturnType + }) + agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1 })) + agent.on('session/new', (frame) => agent.reply(frame, { sessionId: 'session-1' })) + if (behavior.exitOnEnd !== false) { + child.stdin.once('finish', () => child.emit('exit', 0, null)) + } + const connection = createAcpAgentConnection( + { + command: 'fixture-acp-agent', + args: ['--acp'], + cwd: start.cwd, + env: { ACP_ACCOUNT_HOME: '/host/account', STRIPPED: 'overlay' }, + envToDelete: ['STRIPPED'] + }, + options, + spawn + ) + opened.push({ connection, exit: () => child.emit('exit', 0, null) }) + return { agent, child, spawn, connection } +} + +afterEach(async () => { + for (const { connection, exit } of opened.splice(0)) { + exit() + await connection.close() + } + teardown.mockReset().mockResolvedValue('unverifiable') + vi.useRealTimers() +}) + +describe('ACP process-owning connection', () => { + it('spawns through the supervisor with the host launch and exposes typed session calls', async () => { + const { connection, spawn, child, agent } = fixture() + await connection.spawned + expect(connection.pid).toBe(child.pid) + expect(connection.rootVerdict).toBe('live') + expect(spawn).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + cwd: start.cwd, + env: expect.objectContaining({ ACP_ACCOUNT_HOME: '/host/account' }), + stdio: ['pipe', 'pipe', 'pipe'] + }) + ) + expect(spawn.mock.calls[0][0].env).not.toHaveProperty('STRIPPED') + expect(await connection.start(start)).toMatchObject({ sessionId: 'session-1' }) + agent.on('session/prompt', (frame) => agent.reply(frame, { stopReason: 'end_turn' })) + expect(await connection.prompt([...prompt])).toEqual({ stopReason: 'end_turn' }) + connection.pauseReading() + expect(child.stdout.isPaused()).toBe(true) + connection.resumeReading() + expect(child.stdout.isPaused()).toBe(false) + expect(await connection.close()).toBe(true) + }) + + it('settles prompts and permission signals on proven exit with stdout still open', async () => { + const onExit = vi.fn() + const onClose = vi.fn() + const permission = deferred() + const { connection, child, agent } = fixture({ + onExit, + onClose, + onPermission: (_request, context) => { + permission.resolve(context.signal) + return new Promise(() => {}) + } + }) + await connection.start(start) + const rejected = expect(connection.prompt([...prompt])).rejects.toThrow('provider failed') + void agent.request('approval', 'session/request_permission', { + sessionId: 'session-1', + toolCall: { toolCallId: 'tool-1' }, + options: [{ optionId: 'allow', kind: 'allow_once', name: 'Allow' }] + }) + const signal = await permission.promise + child.stderr.write('provider failed\n') + child.emit('exit', 7, null) + child.emit('close', 7, null) + await rejected + expect(signal.aborted).toBe(true) + expect(child.stdout.readableEnded).toBe(false) + expect(connection.exited).toBe(true) + expect(onClose).not.toHaveBeenCalled() + expect(onExit).toHaveBeenCalledExactlyOnceWith(expect.any(Error), { + expected: false, + exit: { code: 7, signal: null, processless: false } + }) + const late = vi.fn() + connection.onExit(late) + expect(late).toHaveBeenCalledOnce() + await expect(connection.close()).resolves.toBe(true) + }) + + it('keeps cancel writable after stdout EOF and waits for process exit evidence', async () => { + const onClose = vi.fn() + const onExit = vi.fn() + const { connection, child, agent } = fixture({ onClose, onExit }) + await connection.start(start) + const rejected = expect(connection.prompt([...prompt])).rejects.toThrow('connection closed') + child.stdout.end() + await tick() + expect(connection.closed).toBe(false) + expect(connection.rootVerdict).toBe('live') + await connection.cancel() + expect(agent.frames.at(-1)?.method).toBe('session/cancel') + expect(onClose).not.toHaveBeenCalled() + expect(onExit).not.toHaveBeenCalled() + expect(await connection.close()).toBe(true) + await rejected + expect(onExit.mock.calls[0][1].expected).toBe(true) + }) + + it('reports broken stdin immediately but reports exit only after the host observes it', async () => { + vi.useFakeTimers() + const onClose = vi.fn() + const onExit = vi.fn() + const { connection, child } = fixture({ onClose, onExit }, { exitOnEnd: false }) + await connection.start(start) + const rejected = expect(connection.prompt([...prompt])).rejects.toThrow('broken pipe') + child.stdin.emit('error', new Error('broken pipe')) + child.emit('close', 0, null) + await rejected + expect(connection.closed).toBe(true) + expect(connection.rootVerdict).toBe('live') + expect(onClose).toHaveBeenCalledOnce() + expect(onExit).not.toHaveBeenCalled() + child.emit('exit', 1, null) + await vi.advanceTimersByTimeAsync(0) + expect(connection.rootVerdict).toBe('exited') + expect(onExit.mock.calls[0][1].expected).toBe(false) + expect(vi.getTimerCount()).toBe(0) + }) + + it('joins physical close and retries unproven exit without spawning a replacement', async () => { + vi.useFakeTimers() + const onExit = vi.fn() + const { connection, child, spawn } = fixture({ onExit }, { exitOnEnd: false }) + const first = connection.close() + const joined = connection.close() + await vi.advanceTimersByTimeAsync(grace + 1_000) + expect(await first).toBe(false) + expect(await joined).toBe(false) + expect(connection.rootVerdict).toBe('live') + expect(teardown).toHaveBeenCalledOnce() + teardown.mockImplementationOnce(async () => { + child.emit('exit', null, 'SIGKILL') + return 'unverifiable' + }) + const retry = connection.close() + await vi.advanceTimersByTimeAsync(grace + 1_000) + expect(await retry).toBe(true) + expect(connection.processTreeUnproven).toBe(true) + expect(spawn).toHaveBeenCalledOnce() + expect(onExit.mock.calls[0][1].expected).toBe(true) + expect(vi.getTimerCount()).toBe(0) + }) + + it('settles a failed spawn but requires processless close evidence before reporting exit', async () => { + const onExit = vi.fn() + const { connection, child, agent } = fixture({ onExit }, { pid: null, exitOnEnd: false }) + agent.on('initialize', () => {}) + const rejected = expect(connection.initialize()).rejects.toThrow('ENOENT') + child.emit('error', new Error('ENOENT')) + await connection.spawned + await rejected + expect(connection.rootVerdict).toBe('unverifiable') + expect(onExit).not.toHaveBeenCalled() + child.emit('close', null, null) + expect(connection.rootVerdict).toBe('exited') + expect(onExit.mock.calls[0][1].exit.processless).toBe(true) + expect(await connection.close()).toBe(true) + }) + + it('owns stderr failure cleanup and isolates failing exit callbacks', async () => { + const { connection, child } = fixture({ + onExit: () => { + throw new Error('listener') + } + }) + await connection.start(start) + const rejected = expect(connection.prompt([...prompt])).rejects.toThrow('stderr failure') + child.stderr.destroy(new Error('stderr failure')) + await rejected + await tick() + expect(connection.exited).toBe(true) + expect(await connection.close()).toBe(true) + }) + + it('rejects invalid peer options before starting any process', () => { + const spawn = vi.fn() + expect(() => + createAcpAgentConnection( + { command: 'fixture', args: [] }, + { peer: { maxPendingRequests: 0 } }, + spawn + ) + ).toThrow('positive finite integers') + expect(spawn).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/acp/acp-agent-connection.ts b/src/main/acp/acp-agent-connection.ts new file mode 100644 index 000000000000..2bd913e07b41 --- /dev/null +++ b/src/main/acp/acp-agent-connection.ts @@ -0,0 +1,136 @@ +import { spawnProcess } from '../../shared/child-process/run-process' +import { + spawnManagedProviderProcess, + type ManagedProviderProcess, + type ProviderProcessExit +} from '../provider-process/managed-provider-process' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import { AcpConnectionClosedError } from './acp-errors' +import { resolveAcpPeerOptions, type AcpPeerOptions } from './acp-peer-limits' +import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' + +export type AcpAgentConnectionOptions = Omit & { + peer?: Omit + /** Process exit evidence, including expected closes and processless spawn failures. */ + onExit?: (error: Error, context: { expected: boolean; exit: ProviderProcessExit }) => void +} + +/** The execution host owns the child and protocol lifetime; the adapter owns turns and Stop. */ +export function createAcpAgentConnection( + launch: ProviderProcessLaunch, + options: AcpAgentConnectionOptions = {}, + spawnImpl: typeof spawnProcess = spawnProcess +): AcpAgentConnection { + return new AcpAgentConnection(launch, options, spawnImpl) +} + +export class AcpAgentConnection extends AcpSessionRuntime { + private readonly managed: ManagedProviderProcess + private readonly lifecycle: { closing: boolean; error?: Error } + readonly spawned: Promise + + constructor( + launch: ProviderProcessLaunch, + options: AcpAgentConnectionOptions = {}, + spawnImpl: typeof spawnProcess = spawnProcess + ) { + // Validate before spawning so invalid limits cannot leave an unowned child. + const peer = resolveAcpPeerOptions({ ...options.peer, closeOnInputEnd: false }) + const managed = spawnManagedProviderProcess(launch, { spawnImpl, site: 'acp-agent-teardown' }) + const lifecycle: { closing: boolean; error?: Error } = { closing: false } + const diagnose = (message: string): void => { + try { + options.onDiagnostic?.(message) + } catch { + /* Diagnostics cannot interrupt cleanup. */ + } + } + super(managed.child.stdout, managed.child.stdin, { + ...options, + peer, + onClose: (error) => { + lifecycle.error ??= error + if (lifecycle.closing || managed.rootVerdict === 'exited') { + return + } + void managed.close().then( + (result) => { + if (result.root !== 'exited') { + diagnose('ACP agent exit was not proven after transport failure') + } + }, + (failure: unknown) => diagnose(`ACP agent cleanup failed: ${String(failure)}`) + ) + options.onClose?.(error) + } + }) + this.managed = managed + this.lifecycle = lifecycle + const { child } = managed + this.spawned = new Promise((resolve) => { + if (child.pid !== undefined) { + resolve() + return + } + const done = (): void => { + child.removeListener('spawn', done) + child.removeListener('error', done) + resolve() + } + child.once('spawn', done) + child.once('error', done) + }) + child.on('error', (error) => super.close(error)) + child.stderr.on('error', (error) => super.close(error)) + managed.onExit((exit) => { + const error = + lifecycle.error ?? + new AcpConnectionClosedError(managed.stderrTail().trim() || `${launch.command} exited`) + super.close(error) + try { + options.onExit?.(error, { expected: lifecycle.closing, exit }) + } catch (failure) { + diagnose(`ACP exit listener failed: ${String(failure)}`) + } + }) + } + + get pid(): number | undefined { + return this.managed.child.pid + } + + get exited(): boolean { + return this.managed.rootVerdict === 'exited' + } + + get rootVerdict(): ManagedProviderProcess['rootVerdict'] { + return this.managed.rootVerdict + } + + get processTreeUnproven(): boolean { + const result = this.managed.lastCloseResult + return result?.root === 'exited' && (result.tree === 'unverifiable' || result.tree === 'live') + } + + stderrTail(): string { + return this.managed.stderrTail().trim() + } + + onExit(listener: (exit: ProviderProcessExit) => void): void { + this.managed.onExit(listener) + } + + pauseReading(): void { + this.managed.child.stdout.pause() + } + + resumeReading(): void { + this.managed.child.stdout.resume() + } + + override close(error?: Error): Promise { + this.lifecycle.closing ||= this.managed.rootVerdict !== 'exited' + super.close(error) + return this.managed.close().then((result) => result.root === 'exited') + } +} diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index b4d57e246cd5..2467cb345cce 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -11,7 +11,8 @@ import { AcpRequestTimeoutError } from './acp-errors' import { AcpIncomingRequests } from './acp-incoming-requests' -import { bounded, requestTimeout } from './acp-peer-limits' +import { requestTimeout, resolveAcpPeerOptions, type AcpPeerOptions } from './acp-peer-limits' +export type { AcpPeerOptions } from './acp-peer-limits' import { settleOversizedAcpLine } from './acp-oversized-lines' import { AcpWriteQueue } from './acp-write-queue' import { detachAcpStreamErrorHandler } from './acp-stdio-error-boundary' @@ -40,14 +41,6 @@ export type AcpPeerHandlers = { onDiagnostic?: (message: string) => void onClose?: (error: Error) => void } -export type AcpPeerOptions = { - maxLineBytes?: number - maxQueuedWriteBytes?: number - maxPendingRequests?: number - maxIncomingRequests?: number - // Caller-selected outgoing deadlines only; omitted or null means unlimited. - requestTimeoutMs?: number | null -} type Pending = { method: string resolve: (value: unknown) => void @@ -66,6 +59,7 @@ export class AcpJsonRpcPeer { private readonly maxPending: number private readonly maxIncoming: number private readonly timeoutMs: number | null + private readonly closeOnInputEnd: boolean constructor( private readonly input: Readable, @@ -73,14 +67,14 @@ export class AcpJsonRpcPeer { private readonly handlers: AcpPeerHandlers = {}, options: AcpPeerOptions = {} ) { - this.maxLineBytes = bounded(options.maxLineBytes, 16 * 1024 * 1024) - this.maxPending = bounded(options.maxPendingRequests, 128) - this.maxIncoming = bounded(options.maxIncomingRequests, 128) - this.timeoutMs = requestTimeout(options.requestTimeoutMs) - this.writer = new AcpWriteQueue( - output, - bounded(options.maxQueuedWriteBytes, 32 * 1024 * 1024), - (error) => this.close(error) + const limits = resolveAcpPeerOptions(options) + this.maxLineBytes = limits.maxLineBytes + this.maxPending = limits.maxPendingRequests + this.maxIncoming = limits.maxIncomingRequests + this.timeoutMs = limits.requestTimeoutMs + this.closeOnInputEnd = limits.closeOnInputEnd + this.writer = new AcpWriteQueue(output, limits.maxQueuedWriteBytes, (error) => + this.close(error) ) this.incoming = new AcpIncomingRequests( handlers.onRequest, @@ -104,13 +98,17 @@ export class AcpJsonRpcPeer { ) input.setEncoding('utf8') input.on('data', this.onData) - input.on('end', this.onEnd) - input.on('close', this.onEnd) + input.on('end', this.onInputEnd) + input.on('close', this.onInputEnd) input.on('error', this.onError) output.on('close', this.onEnd) output.on('finish', this.onEnd) output.on('error', this.onError) - if (input.destroyed || input.readableEnded || output.destroyed || !output.writable) { + if ( + (this.closeOnInputEnd && (input.destroyed || input.readableEnded)) || + output.destroyed || + !output.writable + ) { this.onEnd() } } @@ -178,8 +176,8 @@ export class AcpJsonRpcPeer { } this.terminalError = error this.input.removeListener('data', this.onData) - this.input.removeListener('end', this.onEnd) - this.input.removeListener('close', this.onEnd) + this.input.removeListener('end', this.onInputEnd) + this.input.removeListener('close', this.onInputEnd) detachAcpStreamErrorHandler(this.input, this.onError) this.output.removeListener('close', this.onEnd) this.output.removeListener('finish', this.onEnd) @@ -207,6 +205,11 @@ export class AcpJsonRpcPeer { } } private readonly onEnd = (): void => this.close() + private readonly onInputEnd = (): void => { + if (this.closeOnInputEnd) { + this.close() + } + } private readonly onError = (error: Error): void => this.close(error) private diagnose(message: string): void { try { diff --git a/src/main/acp/acp-peer-limits.ts b/src/main/acp/acp-peer-limits.ts index 51f98809472e..51963e4791aa 100644 --- a/src/main/acp/acp-peer-limits.ts +++ b/src/main/acp/acp-peer-limits.ts @@ -1,5 +1,26 @@ import { isSafeTimerDelayMs } from '../../shared/timer-delay' +export type AcpPeerOptions = { + maxLineBytes?: number + maxQueuedWriteBytes?: number + maxPendingRequests?: number + maxIncomingRequests?: number + requestTimeoutMs?: number | null + /** A managed connection waits for process exit rather than treating stdout EOF as exit. */ + closeOnInputEnd?: boolean +} + +export function resolveAcpPeerOptions(options: AcpPeerOptions = {}): Required { + return { + maxLineBytes: bounded(options.maxLineBytes, 16 * 1024 * 1024), + maxQueuedWriteBytes: bounded(options.maxQueuedWriteBytes, 32 * 1024 * 1024), + maxPendingRequests: bounded(options.maxPendingRequests, 128), + maxIncomingRequests: bounded(options.maxIncomingRequests, 128), + requestTimeoutMs: requestTimeout(options.requestTimeoutMs), + closeOnInputEnd: options.closeOnInputEnd ?? true + } +} + export function bounded(value: number | undefined, fallback: number): number { if (value === undefined) { return fallback diff --git a/src/main/acp/acp-permission-requests.test.ts b/src/main/acp/acp-permission-requests.test.ts index d2f15611725b..67c50eb9ab34 100644 --- a/src/main/acp/acp-permission-requests.test.ts +++ b/src/main/acp/acp-permission-requests.test.ts @@ -99,11 +99,19 @@ describe('ACP permission requests', () => { expect(diagnostics).toEqual([`Answered ACP permission request cancelled: ${problem}`]) }) - it('answers cancelled when the cancel lands before the permission handler starts', async () => { - const asked = vi.fn((_request: RequestPermissionRequest) => new Promise(() => {})) + it('lets the owner decline a permission after cancel, before the handler starts', async () => { + let stopping = false + const asked = vi.fn((_request: RequestPermissionRequest) => ({ + outcome: stopping + ? { outcome: 'cancelled' as const } + : { outcome: 'selected' as const, optionId: 'allow' } + })) const { agent, runtime } = fixture({ onPermission: asked }) await runtime.start(startOptions) - runtime.subscribe(() => void runtime.cancel()) + runtime.subscribe(() => { + stopping = true + void runtime.cancel() + }) const answer = new Promise((resolve) => { agent.stdin.on('data', (chunk: string) => { if (chunk.includes('"id":5')) { @@ -133,15 +141,17 @@ describe('ACP permission requests', () => { .join('') ) expect(await answer).toMatchObject({ id: 5, result: { outcome: { outcome: 'cancelled' } } }) - expect(asked).not.toHaveBeenCalled() + expect(stopping).toBe(true) + expect(asked).toHaveBeenCalledOnce() }) - it('lets a turn the agent began itself ask for permission and cancels it with session/cancel', async () => { + it('keeps an autonomous permission with its owner after session/cancel', async () => { + const decision = deferred() const asked = deferred() const { agent, runtime } = fixture({ onPermission: (_request, context) => { asked.resolve(context.signal) - return new Promise(() => {}) + return decision.promise } }) await runtime.start(startOptions) @@ -152,7 +162,8 @@ describe('ACP permission requests', () => { }) const signal = await asked.promise await runtime.cancel() - expect(signal.aborted).toBe(true) + expect(signal.aborted).toBe(false) + decision.resolve({ outcome: { outcome: 'cancelled' } }) expect(await answer).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) await tick() expect(agent.frames.filter((frame) => frame.method === 'session/cancel')).toHaveLength(1) diff --git a/src/main/acp/acp-prompt-cancel.ts b/src/main/acp/acp-prompt-cancel.ts deleted file mode 100644 index 8938d10aea3d..000000000000 --- a/src/main/acp/acp-prompt-cancel.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { AcpRequestTimeoutError } from './acp-errors' -import type { PromptResponse } from './generated/acp-protocol.generated' - -export type ActivePrompt = { - response: Promise - cancelling: boolean - cancelPromise?: Promise - steerCancel?: Promise -} - -/** How a cancel reaches the agent: the `session/cancel` write and the connection it rides on. */ -export type AcpCancelChannel = { - send: () => Promise - /** Cancels the agent's open requests (permissions, vendor hooks) so each gets its answer. */ - cancelIncomingRequests: () => void - close: (error: Error) => void - closed: () => boolean - timeoutMs: number -} - -/** A Stop's cancel. Sends `session/cancel` whenever the session runs: the agent may be in a turn it - * began itself. With Orca's prompt running, also waits (bounded) for that prompt to settle, and - * past the bound closes the connection, which ends the agent. Repeated asks share one wait. */ -export function cancelAcpPromptForStop( - active: ActivePrompt | undefined, - channel: AcpCancelChannel -): Promise { - channel.cancelIncomingRequests() - if (!active) { - return channel.send() - } - active.cancelling = true - active.cancelPromise ??= confirmAcpPromptCancel(active, channel) - return active.cancelPromise -} - -/** Sends `session/cancel` and waits, bounded, for Orca's prompt to settle; past the bound it closes. */ -async function confirmAcpPromptCancel( - active: ActivePrompt, - connection: AcpCancelChannel -): Promise { - let timer: ReturnType | undefined - const unconfirmed = new Promise((_resolve, reject) => { - timer = setTimeout(() => { - const error = new AcpRequestTimeoutError('session/cancel') - connection.close(error) - reject(error) - }, connection.timeoutMs) - }) - try { - await Promise.race([connection.send(), unconfirmed]).catch((error) => { - // Only a cancel that reached the agent is shared; a failed write is retried next call. - active.cancelPromise = undefined - active.cancelling = false - throw error - }) - await Promise.race([ - active.response.catch((error) => { - if (connection.closed()) { - throw error - } - }), - unconfirmed - ]) - } finally { - clearTimeout(timer) - } -} - -/** A steer's cancel: asks the agent once to end Orca's running prompt so the steer can follow it, - * and resolves when the notification is written. Never bounded and never closes: the prompt's own - * reply ends it, however long that takes, and a later Stop still bounds. A prompt that fails - * instead leaves a session the steer must not be sent into until the caller rebuilds it. With no - * prompt of Orca's there is nothing to end; a Stop already cancelling it owns that cancel. */ -export function requestAcpSteerCancel( - active: ActivePrompt | undefined, - channel: Pick -): Promise { - if (!active) { - return Promise.resolve() - } - if (active.steerCancel || active.cancelling) { - return active.steerCancel ?? Promise.resolve() - } - active.cancelling = true - channel.cancelIncomingRequests() - active.steerCancel = channel.send().catch((error: unknown) => { - // Only a cancel that reached the agent counts; a failed write may be asked again. - active.cancelling = false - active.steerCancel = undefined - throw error - }) - return active.steerCancel -} diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts index 7230870a8c02..a3020620c1e0 100644 --- a/src/main/acp/acp-session-lifecycle.test.ts +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AcpJsonRpcPeer } from './acp-json-rpc-peer' import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' -import { AcpConnectionClosedError, AcpRequestTimeoutError } from './acp-errors' +import { AcpConnectionClosedError, AcpRpcError } from './acp-errors' import type { RequestPermissionResponse } from './generated/acp-protocol.generated' const opened: { close: () => void }[] = [] @@ -219,21 +219,6 @@ describe('ACP caller-owned waits', () => { await rejected }) - it('clamps cancellation confirmation waits to the safe timer range', async () => { - vi.useFakeTimers() - const { agent, runtime } = fixture({ cancelTimeoutMs: 3_000_000_000 }) - agent.on('session/prompt', () => {}) - await runtime.start(startOptions) - const rejected = expect(runtime.prompt([...prompt])).rejects.toBeInstanceOf( - AcpRequestTimeoutError - ) - const cancelled = expect(runtime.cancel()).rejects.toBeInstanceOf(AcpRequestTimeoutError) - await vi.advanceTimersByTimeAsync(1) - expect(agent.frames.at(-1)?.method).toBe('session/cancel') - await vi.advanceTimersByTimeAsync(2_147_483_646) - await Promise.all([rejected, cancelled]) - }) - it('lets initialize and explicit authentication wait for slow first-run startup and login', async () => { vi.useFakeTimers() const { agent, runtime } = fixture({ peer: { requestTimeoutMs: 20 } }) @@ -248,16 +233,17 @@ describe('ACP caller-owned waits', () => { await expect(authenticated).resolves.toEqual({}) }) - it('lets cancel abort vendor hooks so each sends its own answer, or request-cancelled if it throws', async () => { + it('lets request owners withdraw vendor hooks after cancel', async () => { + const withdrawal = new AbortController() const { agent, runtime } = fixture({ - onRequest: (method, _params, context) => + onRequest: (method) => new Promise((resolve, reject) => { // '_vendor/silent' ignores the abort: the runtime never answers for it. if (method !== '_vendor/silent') { - context.signal.addEventListener('abort', () => + withdrawal.signal.addEventListener('abort', () => method === '_vendor/plan' ? resolve({ outcome: 'abandoned' }) - : reject(new Error('stop')) + : reject(new AcpRpcError(-32800, 'stop')) ) } }) @@ -272,6 +258,7 @@ describe('ACP caller-owned waits', () => { void agent.request('silent', '_vendor/silent', {}) await tick() await runtime.cancel() + withdrawal.abort() expect(await question).toMatchObject({ error: { code: -32800 } }) expect(await plan).toMatchObject({ result: { outcome: 'abandoned' } }) await pending @@ -280,18 +267,19 @@ describe('ACP caller-owned waits', () => { expect(agent.frames.some((frame) => frame.id === 'silent')).toBe(false) }) - it('keeps a handler answer that finishes saving after the cancel', async () => { + it('keeps a handler answer that finishes saving after its owner withdraws requests', async () => { + const withdrawal = new AbortController() // A real I/O hop, the shape of a journal write the handler commits before replying. const save = (): Promise => readFile(import.meta.filename).then(() => undefined) const userAnswer = deferred() const { agent, runtime } = fixture({ - onRequest: (method, _params, context) => + onRequest: (method) => new Promise((resolve) => { if (method === '_vendor/plan') { // The user already approved; the save started before the stop and replies after it. void userAnswer.promise.then(save).then(() => resolve({ outcome: 'approved' })) } else { - context.signal.addEventListener( + withdrawal.signal.addEventListener( 'abort', () => void save().then(() => resolve({ outcome: 'abandoned' })) ) @@ -308,6 +296,7 @@ describe('ACP caller-owned waits', () => { await tick() userAnswer.resolve() await runtime.cancel() + withdrawal.abort() expect(await plan).toMatchObject({ result: { outcome: 'approved' } }) expect(await question).toMatchObject({ result: { outcome: 'abandoned' } }) await pending @@ -316,7 +305,7 @@ describe('ACP caller-owned waits', () => { expect(agent.frames.filter((frame) => frame.id === 'question')).toHaveLength(1) }) - it('settles requests when the process owner closes on exit even if stdout stays open', async () => { + it('settles stream-level requests on close even if stdout stays open', async () => { const { agent, runtime } = fixture() agent.on('session/prompt', () => {}) await runtime.start(startOptions) @@ -326,7 +315,7 @@ describe('ACP caller-owned waits', () => { await rejected expect(agent.stdout.readableEnded).toBe(false) }) - it.each(['answer', 'cancel'] as const)( + it.each(['answer', 'close'] as const)( 'keeps a permission pending after prompt completion until caller %s', async (action) => { const decision = deferred() @@ -354,13 +343,13 @@ describe('ACP caller-owned waits', () => { result: { outcome: { outcome: 'selected', optionId: 'allow' } } }) } else { - await runtime.cancel() - expect(await response).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + runtime.close() + expect(signal.aborted).toBe(true) } } ) - it('confirms cancellation when the agent settles the prompt with an error', async () => { + it('writes cancel independently of the agent settling the prompt with an error', async () => { const { agent, runtime } = fixture() agent.on('session/prompt', (frame) => { agent.on('session/cancel', () => agent.fail(frame, -32800, 'Request cancelled')) diff --git a/src/main/acp/acp-session-runtime.test.ts b/src/main/acp/acp-session-runtime.test.ts index ef5369e8589f..6b1dff7cc1b8 100644 --- a/src/main/acp/acp-session-runtime.test.ts +++ b/src/main/acp/acp-session-runtime.test.ts @@ -1,16 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { - AcpAuthRequiredError, - AcpConnectionClosedError, - AcpRequestTimeoutError, - AcpRpcError -} from './acp-errors' +import { AcpAuthRequiredError, AcpConnectionClosedError, AcpRpcError } from './acp-errors' import { AcpSessionRuntime, type AcpSessionRuntimeOptions, type AcpSessionEvent } from './acp-session-runtime' -import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' +import { AcpScriptedAgent, deferred } from './acp-scripted-agent.test-support' import type { AgentCapabilities, RequestPermissionResponse @@ -162,10 +157,9 @@ describe('ACP session runtime', () => { }) }) - it('cancels open and late permissions and waits for the agent cancellation response', async () => { + it('leaves open and late permission decisions with their owner after cancel', async () => { const requested = deferred() const decision = deferred() - const responses: unknown[] = [] const { runtime, agent } = fixture( {}, { @@ -175,35 +169,23 @@ describe('ACP session runtime', () => { } } ) - agent.on('session/prompt', (frame) => { - void agent - .request('permission', 'session/request_permission', permission) - .then((response) => responses.push(response.result)) - agent.on('session/cancel', (cancel) => { - expect(cancel.id).toBeUndefined() - void agent - .request('late-permission', 'session/request_permission', permission) - .then((response) => { - responses.push(response.result) - agent.reply(frame, { stopReason: 'cancelled' }) - }) - }) - }) + agent.on('session/prompt', () => {}) await runtime.start(startOptions) const prompt = runtime.prompt([...textPrompt]) + const open = agent.request('open', 'session/request_permission', permission) const signal = await requested.promise - const cancel = runtime.cancel() - expect(runtime.cancel()).toBe(cancel) - expect(await prompt).toEqual({ stopReason: 'cancelled' }) - await cancel - expect(signal.aborted).toBe(true) - expect(responses).toEqual([ - { outcome: { outcome: 'cancelled' } }, - { outcome: { outcome: 'cancelled' } } - ]) - decision.resolve({ outcome: { outcome: 'selected', optionId: 'allow' } }) - await tick() - expect(agent.frames.filter((frame) => frame.id === 'permission')).toHaveLength(1) + await runtime.cancel() + expect(signal.aborted).toBe(false) + const late = agent.request('late', 'session/request_permission', permission) + decision.resolve({ outcome: { outcome: 'cancelled' } }) + expect(await open).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect(await late).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + const frame = agent.frames.find((frame) => frame.method === 'session/prompt') + if (!frame) { + throw new Error('missing prompt') + } + agent.reply(frame, { stopReason: 'cancelled' }) + await prompt }) it('surfaces authentication-required errors with code and data', async () => { @@ -409,88 +391,24 @@ describe('ACP session runtime', () => { await expect(runtime.setMode('plan')).rejects.toBeInstanceOf(AcpConnectionClosedError) }) - it('closes an unconfirmed cancellation rather than allowing another prompt', async () => { + it('writes cancel immediately without waiting, coalescing, timing out, or closing', async () => { vi.useFakeTimers() - const { runtime, agent } = fixture({}, { cancelTimeoutMs: 100 }) - agent.on('session/prompt', () => {}) + const { runtime, agent } = fixture() + const frame = deferred[0]>() + agent.on('session/prompt', (prompt) => frame.resolve(prompt)) await runtime.start(startOptions) const prompt = runtime.prompt([...textPrompt]) - const rejectedPrompt = expect(prompt).rejects.toBeInstanceOf(AcpRequestTimeoutError) - const cancelled = expect(runtime.cancel()).rejects.toBeInstanceOf(AcpRequestTimeoutError) - await vi.advanceTimersByTimeAsync(100) - await Promise.all([rejectedPrompt, cancelled]) - await expect(runtime.prompt([...textPrompt])).rejects.toBeInstanceOf(AcpRequestTimeoutError) - }) - - describe("a steer's cancel", () => { - const cancels = (agent: AcpScriptedAgent) => - agent.frames.filter((frame) => frame.method === 'session/cancel') - - it("asks once, never times out or closes, and the prompt's own reply ends it", async () => { - vi.useFakeTimers() - const signals: AbortSignal[] = [] - const { runtime, agent } = fixture( - {}, - { - cancelTimeoutMs: 100, - // Would allow every request it is asked; a steer's cancel keeps late ones from it. - onPermission: (_request, context) => { - signals.push(context.signal) - return new Promise(() => {}) - } - } - ) - const promptFrame = deferred[0]>() - agent.on('session/prompt', (frame) => promptFrame.resolve(frame)) - await runtime.start(startOptions) - const prompt = runtime.prompt([...textPrompt]) - const frame = await promptFrame.promise - const open = agent.request('open', 'session/request_permission', permission) - await vi.waitFor(() => expect(signals).toHaveLength(1)) - - const steer = runtime.requestSteerCancel() - expect(runtime.requestSteerCancel()).toBe(steer) - await steer - await vi.advanceTimersByTimeAsync(1_000) - - expect(cancels(agent)).toHaveLength(1) - expect(signals[0]?.aborted).toBe(true) - expect(await open).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) - expect(await agent.request('late', 'session/request_permission', permission)).toMatchObject({ - result: { outcome: { outcome: 'cancelled' } } - }) - expect(signals).toHaveLength(1) - agent.reply(frame, { stopReason: 'cancelled' }) - expect(await prompt).toEqual({ stopReason: 'cancelled' }) - // The connection is still open, so the steer's own prompt follows. - agent.on('session/prompt', (next) => agent.reply(next, { stopReason: 'end_turn' })) - expect(await runtime.prompt([...textPrompt])).toEqual({ stopReason: 'end_turn' }) - }) - - it('leaves a later Stop bounded, closing the connection when the prompt never settles', async () => { - vi.useFakeTimers() - const { runtime, agent } = fixture({}, { cancelTimeoutMs: 100 }) - agent.on('session/prompt', () => {}) - await runtime.start(startOptions) - const prompt = runtime.prompt([...textPrompt]) - const rejectedPrompt = expect(prompt).rejects.toBeInstanceOf(AcpRequestTimeoutError) - - await runtime.requestSteerCancel() - const stopped = expect(runtime.cancel()).rejects.toBeInstanceOf(AcpRequestTimeoutError) - await vi.advanceTimersByTimeAsync(100) - - await Promise.all([rejectedPrompt, stopped]) - expect(cancels(agent)).toHaveLength(2) - await expect(runtime.prompt([...textPrompt])).rejects.toBeInstanceOf(AcpRequestTimeoutError) - }) - - it("writes nothing with no prompt of Orca's running", async () => { - const { runtime, agent } = fixture() - await expect(runtime.requestSteerCancel()).resolves.toBeUndefined() - await runtime.start(startOptions) - await runtime.requestSteerCancel() - await tick() - expect(cancels(agent)).toHaveLength(0) - }) + const sent = await frame.promise + await runtime.cancel() + await runtime.cancel() + expect(agent.frames.filter((frame) => frame.method === 'session/cancel')).toHaveLength(2) + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(120_000) + expect(runtime.closed).toBe(false) + await expect(runtime.prompt([...textPrompt])).rejects.toThrow('already in progress') + agent.reply(sent, { stopReason: 'cancelled' }) + expect(await prompt).toEqual({ stopReason: 'cancelled' }) + agent.on('session/prompt', (next) => agent.reply(next, { stopReason: 'end_turn' })) + expect(await runtime.prompt([...textPrompt])).toEqual({ stopReason: 'end_turn' }) }) }) diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index eb6ceb821947..e205c1f67c46 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -1,6 +1,5 @@ import type { Readable, Writable } from 'node:stream' import type { z } from 'zod' -import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' import { AcpAgentError, AcpAuthRequiredError, @@ -14,12 +13,6 @@ import { type AcpPermissionHandler } from './acp-permission-requests' import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' -import { - cancelAcpPromptForStop, - requestAcpSteerCancel, - type AcpCancelChannel, - type ActivePrompt -} from './acp-prompt-cancel' import { setupAcpSession, type AcpSessionStarted, @@ -55,7 +48,6 @@ const withMeta = (meta: Meta): { _meta?: Meta } => (meta ? { _meta: meta } : {}) export type AcpSessionRuntimeOptions = { clientInfo?: InitializeRequest['clientInfo'] peer?: AcpPeerOptions - cancelTimeoutMs?: number onPermission?: AcpPermissionHandler /** Agent requests other than permissions. The handler owns its request: once `context.signal` * aborts, send the agent's own cancelled reply, finish an answer already in progress, or throw @@ -68,13 +60,14 @@ export type AcpSessionRuntimeOptions = { onClose?: (error: Error) => void } +/** Stream-level protocol seam; production agents use createAcpAgentConnection. */ export class AcpSessionRuntime { private readonly peer: AcpJsonRpcPeer private readonly listeners = new Set<(event: AcpSessionEvent) => void>() private initialized?: Promise private starting?: Promise private started?: AcpSessionStarted - private activePrompt?: ActivePrompt + private activePrompt?: Promise private reportedUpdateAnomaly = false constructor( @@ -82,10 +75,6 @@ export class AcpSessionRuntime { output: Writable, private readonly options: AcpSessionRuntimeOptions = {} ) { - const timeout = options.cancelTimeoutMs - if (timeout !== undefined && (!Number.isSafeInteger(timeout) || timeout <= 0)) { - throw new Error('ACP timeouts must be positive finite timer durations') - } this.peer = new AcpJsonRpcPeer( input, output, @@ -99,6 +88,10 @@ export class AcpSessionRuntime { ) } + get closed(): boolean { + return this.peer.closed + } + subscribe(listener: (event: AcpSessionEvent) => void): () => void { this.listeners.add(listener) return () => { @@ -165,32 +158,27 @@ export class AcpSessionRuntime { throw new Error('ACP prompt already in progress') } const params: PromptRequest = { sessionId: this.sessionId(), prompt, ...withMeta(meta) } - const active: ActivePrompt = { - cancelling: false, - response: this.call('session/prompt', params, PromptResponseSchema) - } - this.activePrompt = active - active.response = active.response.finally(() => { - if (this.activePrompt === active) { + const response = this.call('session/prompt', params, PromptResponseSchema) + this.activePrompt = response + try { + return await response + } finally { + if (this.activePrompt === response) { this.activePrompt = undefined } - }) - return active.response + } } - /** A Stop's cancel (`cancelAcpPromptForStop`): bounded, and past the bound it closes. A prompt - * that settles in time leaves the agent running; the Stop's owner ends its process. */ + /** Stop and steering share this notification; the adapter owns their completion policy. */ cancel(options: { meta?: CancelNotification['_meta'] } = {}): Promise { - const channel = this.cancelChannel(options.meta) - return channel - ? cancelAcpPromptForStop(this.activePrompt, channel) - : Promise.reject(new Error('ACP session has not started')) - } - - /** A steer's cancel (`requestAcpSteerCancel`): never bounded and never closes. */ - requestSteerCancel(options: { meta?: CancelNotification['_meta'] } = {}): Promise { - const channel = this.cancelChannel(options.meta) - return channel ? requestAcpSteerCancel(this.activePrompt, channel) : Promise.resolve() + if (!this.started) { + return Promise.reject(new Error('ACP session has not started')) + } + const params: CancelNotification = { + sessionId: this.started.sessionId, + ...withMeta(options.meta) + } + return this.peer.notify('session/cancel', params) } async setMode(modeId: string, meta?: Meta): Promise { @@ -229,26 +217,11 @@ export class AcpSessionRuntime { return this.call('session/set_config_option', request, SetSessionConfigOptionResponseSchema) } - // The process owner must call close on child exit, even if descendants keep stdio open. close(error?: Error): void { this.peer.close(error) this.listeners.clear() } - private cancelChannel(meta: CancelNotification['_meta']): AcpCancelChannel | null { - if (!this.started) { - return null - } - const params: CancelNotification = { sessionId: this.started.sessionId, ...withMeta(meta) } - return { - send: () => this.peer.notify('session/cancel', params), - cancelIncomingRequests: () => this.peer.cancelIncomingRequests(), - close: (error) => this.peer.close(error), - closed: () => this.peer.closed, - timeoutMs: Math.min(this.options.cancelTimeoutMs ?? 10_000, MAX_TIMER_DELAY_MS) - } - } - private sessionId(): string { if (!this.started) { throw new Error('ACP session has not started') @@ -287,7 +260,7 @@ export class AcpSessionRuntime { throw new AcpRpcError(-32602, 'Invalid ACP permission request') } // Whether a turn the agent began itself may ask is the caller's call; the runtime cannot see it. - if (this.activePrompt?.cancelling || request.sessionId !== this.started?.sessionId) { + if (request.sessionId !== this.started?.sessionId) { return { outcome: { outcome: 'cancelled' } } } return answerAcpPermission(request, context, this.options.onPermission, diagnose) From 613ca21e2cb534e88d00d09f6a815e082231b723 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:42:31 -0700 Subject: [PATCH 245/269] Preserve ACP cleanup evidence and isolate exit observers --- src/main/acp/acp-agent-connection.test.ts | 42 ++++++++++++++++++++++- src/main/acp/acp-agent-connection.ts | 13 +++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/main/acp/acp-agent-connection.test.ts b/src/main/acp/acp-agent-connection.test.ts index c4a7edeaee8c..da2e67e537b3 100644 --- a/src/main/acp/acp-agent-connection.test.ts +++ b/src/main/acp/acp-agent-connection.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { spawnProcess } from '../../shared/child-process/run-process' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' import { ROOT_ONLY_GRACEFUL_EXIT_MS } from '../provider-process/provider-process-close' +import type { terminateProviderProcessTree } from '../provider-process/provider-process-teardown' import { createAcpAgentConnection, type AcpAgentConnection, @@ -11,7 +12,9 @@ import { } from './acp-agent-connection' import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' -const teardown = vi.hoisted(() => vi.fn(async () => 'unverifiable' as const)) +const teardown = vi.hoisted(() => + vi.fn(async () => 'unverifiable') +) vi.mock('../provider-process/provider-process-teardown', () => ({ terminateProviderProcessTree: teardown })) @@ -193,6 +196,43 @@ describe('ACP process-owning connection', () => { expect(vi.getTimerCount()).toBe(0) }) + it.each(['live', 'unverifiable'] as const)( + 'retains %s tree evidence when root exits between completed close attempts', + async (tree) => { + vi.useFakeTimers() + const { connection, child } = fixture({}, { exitOnEnd: false }) + teardown.mockResolvedValueOnce(tree) + const close = connection.close() + await vi.advanceTimersByTimeAsync(grace + 1_000) + expect(await close).toBe(false) + child.emit('exit', null, 'SIGKILL') + expect(connection.processTreeUnproven).toBe(true) + expect(await connection.close()).toBe(true) + expect(connection.processTreeUnproven).toBe(true) + expect(teardown).toHaveBeenCalledOnce() + } + ) + + it('isolates early and late exit subscribers and continues delivering exit', () => { + const onDiagnostic = vi.fn(() => { + throw new Error('diagnostic') + }) + const { connection, child } = fixture({ onDiagnostic }) + const failing = (): void => { + throw new Error('observer') + } + const early = vi.fn() + connection.onExit(failing) + connection.onExit(early) + expect(() => child.emit('exit', 0, null)).not.toThrow() + expect(early).toHaveBeenCalledExactlyOnceWith({ code: 0, signal: null, processless: false }) + expect(() => connection.onExit(failing)).not.toThrow() + const late = vi.fn() + connection.onExit(late) + expect(late).toHaveBeenCalledOnce() + expect(onDiagnostic).toHaveBeenCalledTimes(2) + }) + it('settles a failed spawn but requires processless close evidence before reporting exit', async () => { const onExit = vi.fn() const { connection, child, agent } = fixture({ onExit }, { pid: null, exitOnEnd: false }) diff --git a/src/main/acp/acp-agent-connection.ts b/src/main/acp/acp-agent-connection.ts index 2bd913e07b41..e06afe07cac6 100644 --- a/src/main/acp/acp-agent-connection.ts +++ b/src/main/acp/acp-agent-connection.ts @@ -27,6 +27,7 @@ export function createAcpAgentConnection( export class AcpAgentConnection extends AcpSessionRuntime { private readonly managed: ManagedProviderProcess private readonly lifecycle: { closing: boolean; error?: Error } + private readonly diagnoseLifecycle: (message: string) => void readonly spawned: Promise constructor( @@ -66,6 +67,7 @@ export class AcpAgentConnection extends AcpSessionRuntime { }) this.managed = managed this.lifecycle = lifecycle + this.diagnoseLifecycle = diagnose const { child } = managed this.spawned = new Promise((resolve) => { if (child.pid !== undefined) { @@ -107,9 +109,10 @@ export class AcpAgentConnection extends AcpSessionRuntime { return this.managed.rootVerdict } + /** Reports retained cleanup uncertainty; false does not prove descendant exit. */ get processTreeUnproven(): boolean { const result = this.managed.lastCloseResult - return result?.root === 'exited' && (result.tree === 'unverifiable' || result.tree === 'live') + return this.exited && (result?.tree === 'unverifiable' || result?.tree === 'live') } stderrTail(): string { @@ -117,7 +120,13 @@ export class AcpAgentConnection extends AcpSessionRuntime { } onExit(listener: (exit: ProviderProcessExit) => void): void { - this.managed.onExit(listener) + this.managed.onExit((exit) => { + try { + listener(exit) + } catch (failure) { + this.diagnoseLifecycle(`ACP exit listener failed: ${String(failure)}`) + } + }) } pauseReading(): void { From 20f7f8a9531afd7132c8e3f0f28939184e0c3ba1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:50:48 -0700 Subject: [PATCH 246/269] Expose ACP cleanup observations and type the permission fixture --- src/main/acp/acp-agent-connection.test.ts | 3 +++ src/main/acp/acp-agent-connection.ts | 7 ++++++- src/main/acp/acp-permission-requests.test.ts | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/acp/acp-agent-connection.test.ts b/src/main/acp/acp-agent-connection.test.ts index da2e67e537b3..46fad1e969f2 100644 --- a/src/main/acp/acp-agent-connection.test.ts +++ b/src/main/acp/acp-agent-connection.test.ts @@ -76,6 +76,7 @@ describe('ACP process-owning connection', () => { await connection.spawned expect(connection.pid).toBe(child.pid) expect(connection.rootVerdict).toBe('live') + expect(connection.lastCloseResult).toBeNull() expect(spawn).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ cwd: start.cwd, @@ -92,6 +93,7 @@ describe('ACP process-owning connection', () => { connection.resumeReading() expect(child.stdout.isPaused()).toBe(false) expect(await connection.close()).toBe(true) + expect(connection.lastCloseResult).toEqual({ root: 'exited', tree: null }) }) it('settles prompts and permission signals on proven exit with stdout still open', async () => { @@ -209,6 +211,7 @@ describe('ACP process-owning connection', () => { expect(connection.processTreeUnproven).toBe(true) expect(await connection.close()).toBe(true) expect(connection.processTreeUnproven).toBe(true) + expect(connection.lastCloseResult).toEqual({ root: 'live', tree }) expect(teardown).toHaveBeenCalledOnce() } ) diff --git a/src/main/acp/acp-agent-connection.ts b/src/main/acp/acp-agent-connection.ts index e06afe07cac6..d4f7eaf2ec25 100644 --- a/src/main/acp/acp-agent-connection.ts +++ b/src/main/acp/acp-agent-connection.ts @@ -109,9 +109,14 @@ export class AcpAgentConnection extends AcpSessionRuntime { return this.managed.rootVerdict } + /** Retained evidence from the last close attempt; a null tree means no observation. */ + get lastCloseResult(): Readonly { + return this.managed.lastCloseResult + } + /** Reports retained cleanup uncertainty; false does not prove descendant exit. */ get processTreeUnproven(): boolean { - const result = this.managed.lastCloseResult + const result = this.lastCloseResult return this.exited && (result?.tree === 'unverifiable' || result?.tree === 'live') } diff --git a/src/main/acp/acp-permission-requests.test.ts b/src/main/acp/acp-permission-requests.test.ts index 67c50eb9ab34..d04649beb361 100644 --- a/src/main/acp/acp-permission-requests.test.ts +++ b/src/main/acp/acp-permission-requests.test.ts @@ -146,7 +146,7 @@ describe('ACP permission requests', () => { }) it('keeps an autonomous permission with its owner after session/cancel', async () => { - const decision = deferred() + const decision = deferred<{ outcome: { outcome: 'cancelled' } }>() const asked = deferred() const { agent, runtime } = fixture({ onPermission: (_request, context) => { From 0ef6d2181578e071134d023ed916f5c165b27408 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:27:23 -0700 Subject: [PATCH 247/269] refactor(native-chat): the registered-agents capability lives in its own module Main's growth put protocol-version.ts one counted line over its 300-line limit once the capability was added; like main's other per-feature capabilities, it now has its own module, and importers read it from there. --- src/main/ipc/desktop-renderer-runtime-capabilities.ts | 2 +- .../rpc/methods/agent-launch-caller-renders.test.ts | 6 ++---- .../session-tab-registered-agent-projection.test.ts | 2 +- .../rpc/methods/structured-agent-session-policy.ts | 2 +- ...tructured-agent-session-restart-dismiss-fence.test.ts | 2 +- .../structured-agent-session-restart-resume.test.ts | 2 +- .../src/lib/agent-launch-route-registered-agents.test.ts | 2 +- src/renderer/src/runtime/host-structured-agents.test.ts | 6 ++---- src/renderer/src/runtime/host-structured-agents.ts | 2 +- .../electron-remote-runtime-client-capabilities.ts | 2 +- src/shared/protocol-version.ts | 9 +-------- ...uctured-agent-session-registered-agents-capability.ts | 8 ++++++++ src/shared/structured-native-chat-launch-route.test.ts | 6 ++---- src/shared/structured-native-chat-launch-route.ts | 2 +- .../cross-version-registered-agents.unit.test.ts | 2 +- 15 files changed, 25 insertions(+), 30 deletions(-) create mode 100644 src/shared/structured-agent-session-registered-agents-capability.ts diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 1c627e2618ca..6fe304ebef57 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -7,10 +7,10 @@ import { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../shared/structured-agent-session-registered-agents-capability' import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../shared/agent-launch-runtime-capability' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shared/agent-session-background-task-child-views-capability' diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts b/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts index 9ad8705529b6..712345ff8233 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts @@ -7,10 +7,8 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from '../../../../shared/protocol-version' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/structured-agent-session-registered-agents-capability' import type { RpcContext } from '../core' import { CAPABLE_CLIENT, diff --git a/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts index bc446197ca0f..692d8f783089 100644 --- a/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/structured-agent-session-registered-agents-capability' import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' import { STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts index 479b67289c93..d5ceb7b19635 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -1,10 +1,10 @@ import { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../../shared/protocol-version' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/structured-agent-session-registered-agents-capability' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcContext } from '../core' diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts index 2750b8e62267..ad2bc83c7d43 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts @@ -10,7 +10,7 @@ import { AgentSessionRecoveryCapsule } from '../../agent-session-recovery-capsule' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' -import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/structured-agent-session-registered-agents-capability' import { CLAUDE_STRUCTURED_AGENT } from '../../../claude/claude-structured-agent-definition' import { CODEX_STRUCTURED_AGENT } from '../../../codex/codex-structured-agent-definition' import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts index 42b5a64bdb5d..edbf0258db97 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts @@ -8,7 +8,7 @@ import { restartRowsFor, type StructuredAgentSessionRestartAudience } from '../../../native-chat/agent-session-wire/structured-agent-session-restart-resume-set' -import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/structured-agent-session-registered-agents-capability' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' import { call, diff --git a/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts b/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts index db5024bbb897..58044e274246 100644 --- a/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts +++ b/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../shared/protocol-version' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../shared/structured-agent-session-registered-agents-capability' import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' import type { TuiAgent } from '../../../shared/tui-agent' diff --git a/src/renderer/src/runtime/host-structured-agents.test.ts b/src/renderer/src/runtime/host-structured-agents.test.ts index 41166e2c0e98..73f1bfa92371 100644 --- a/src/renderer/src/runtime/host-structured-agents.test.ts +++ b/src/renderer/src/runtime/host-structured-agents.test.ts @@ -1,9 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../shared/electron-remote-runtime-client-capabilities' -import { - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from '../../../shared/protocol-version' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../shared/structured-agent-session-registered-agents-capability' import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' import type { RuntimeCapability } from '../../../shared/protocol-version' diff --git a/src/renderer/src/runtime/host-structured-agents.ts b/src/renderer/src/runtime/host-structured-agents.ts index 4196adff0336..af35cd2cc8f8 100644 --- a/src/renderer/src/runtime/host-structured-agents.ts +++ b/src/renderer/src/runtime/host-structured-agents.ts @@ -4,7 +4,7 @@ import { type AgentSessionRegisteredAgent } from '../../../shared/agent-session-registered-agents' import { parseExecutionHostId } from '../../../shared/execution-host' -import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../shared/structured-agent-session-registered-agents-capability' import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' import { callRuntimeRpc } from './runtime-rpc-client' diff --git a/src/shared/electron-remote-runtime-client-capabilities.ts b/src/shared/electron-remote-runtime-client-capabilities.ts index 17bdebceb79c..817a73e3a203 100644 --- a/src/shared/electron-remote-runtime-client-capabilities.ts +++ b/src/shared/electron-remote-runtime-client-capabilities.ts @@ -7,10 +7,10 @@ import { NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY } from './protocol-version' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from './structured-agent-session-registered-agents-capability' // Electron clients can decode client-hosted page placement; becoming a page // host still requires the separate authenticated browser-client lease. diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index a90cbf911d71..06e2b381ea0b 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -14,6 +14,7 @@ export { } from './agent-session-resume-runtime-capabilities' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability' import { AGENT_SESSION_STOP_RUNTIME_CAPABILITIES } from './agent-session-stop-capabilities' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from './structured-agent-session-registered-agents-capability' import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' import { SKILL_BUNDLE_INSTALL_CAPABILITY, @@ -233,14 +234,6 @@ export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = // journal and lifecycle surfaces independently from Codex support. export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.claude.v1' as const -// Why: a host's structured agents are the ones it registered, not a list every build ships. A host -// advertising this accepts any agent it lists through `agentSession.agents` (with each agent's -// capability record) in `agentSession.*` params, and refuses one it did not register; a client -// offers an agent beyond Claude and Codex only to such a host. A client advertising it renders an -// `agent-session` tab of any agent its host lists; the host withholds every other agent's tabs from -// a client that does not (an older client would list them with an empty pane). -export const STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY = - 'agent-session.structured.registered-agents.v1' as const // Why: paired structured clients explicitly hold every visible session surface, allowing the host // to stop provider children after the last surface closes without tying lifetime to a transport. export const STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY = diff --git a/src/shared/structured-agent-session-registered-agents-capability.ts b/src/shared/structured-agent-session-registered-agents-capability.ts new file mode 100644 index 000000000000..1f19a99e42e1 --- /dev/null +++ b/src/shared/structured-agent-session-registered-agents-capability.ts @@ -0,0 +1,8 @@ +// Why: a host's structured agents are the ones it registered, not a list every build ships. A host +// advertising this accepts any agent it lists through `agentSession.agents` (with each agent's +// capability record) in `agentSession.*` params, and refuses one it did not register; a client +// offers an agent beyond Claude and Codex only to such a host. A client advertising it renders an +// `agent-session` tab of any agent its host lists; the host withholds every other agent's tabs from +// a client that does not (an older client would list them with an empty pane). +export const STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY = + 'agent-session.structured.registered-agents.v1' as const diff --git a/src/shared/structured-native-chat-launch-route.test.ts b/src/shared/structured-native-chat-launch-route.test.ts index cb4b48d3a9d5..6c4acfb26d18 100644 --- a/src/shared/structured-native-chat-launch-route.test.ts +++ b/src/shared/structured-native-chat-launch-route.test.ts @@ -4,10 +4,8 @@ */ import { describe, expect, it } from 'vitest' -import { - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from './protocol-version' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from './protocol-version' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from './structured-agent-session-registered-agents-capability' import { agentTabsDefaultToNativeChat, prefersStructuredNativeChatByDefault, diff --git a/src/shared/structured-native-chat-launch-route.ts b/src/shared/structured-native-chat-launch-route.ts index 9f134bc95833..9f24eab93a38 100644 --- a/src/shared/structured-native-chat-launch-route.ts +++ b/src/shared/structured-native-chat-launch-route.ts @@ -14,9 +14,9 @@ import type { GlobalSettings } from './global-settings-types' import type { ProjectExecutionRuntimeResolution } from './project-execution-runtime' import { STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from './protocol-version' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from './structured-agent-session-registered-agents-capability' import type { TuiAgent } from './tui-agent' import type { WorkspaceLaunchKind } from './workspace-launch-kind' diff --git a/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts index 2a2f670465c4..ab2c98bb4f3e 100644 --- a/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts @@ -25,9 +25,9 @@ import type { AgentSessionRecord } from '../../../src/shared/agent-session-recor import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' import { RUNTIME_CAPABILITIES, - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../src/shared/structured-agent-session-registered-agents-capability' import type { RuntimeMobileSessionTabsSnapshot } from '../../../src/shared/runtime-types' import { resolveStructuredNativeChatSupport } from '../../../src/shared/structured-native-chat-launch-route' import { parseWorkspaceSession } from '../../../src/shared/workspace-session-schema' From 03584f0f357c5edfcdc177173694e48ddd3a28fa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:52:48 -0700 Subject: [PATCH 248/269] refactor(acp): one connection owns the Grok process and its protocol D3 now opens each ACP agent through createAcpAgentConnection (ACP-ALIGN #25810): one object spawns the process on the execution host, owns its stdio and protocol, and reports its proven exit. It is built and tracked before the handshake, so a start's abort (Close, Stop, quit) still reaches it, and a failed start keeps that same connection for the next close to retry rather than spawning another process. Deleted: the spawnAcpStructuredChild wrapper and its test, the raw-stream runtime assembly, the caller's exit -> runtime.close wiring, the stdout-EOF heuristic (the connection no longer treats stdout EOF as exit), and the 10 s steer/Stop cancel bound with requestSteerCancel. Reader control maps to pauseReading/resumeReading; a close is connection.close after the host's existing 4 s Stop grace. The adapter owns what the protocol no longer does: one session/cancel per running prompt however many steers arrive (cleared with that send's settlement, retried after a failed write), and a Stop or steer answers every open agent request the person has not already answered with the agent's own cancelled reply. An answer already being saved when the Stop lands is sent. Tests: blocked cancel write never holds Stop's grace, two quick steers send one cancel, a failed cancel write is retried, a real process exiting while a child holds its stdout ends the session, and the existing start-abort, retention, crash, connection-loss and reload-failure suites on the new rig. --- src/main/acp/acp-structured-acquire.ts | 190 ++++++++---------- .../acp-structured-adapter.test-support.ts | 88 +++++--- .../acp/acp-structured-broken-stream.test.ts | 97 +++++++-- src/main/acp/acp-structured-child.test.ts | 58 ------ src/main/acp/acp-structured-child.ts | 86 -------- src/main/acp/acp-structured-connection.ts | 54 +++++ .../acp/acp-structured-electron-free.test.ts | 2 +- .../acp/acp-structured-host-crash.test.ts | 2 +- .../acp/acp-structured-host-lifecycle.test.ts | 9 +- src/main/acp/acp-structured-host-stop.test.ts | 20 ++ src/main/acp/acp-structured-options.ts | 14 +- src/main/acp/acp-structured-prompts.ts | 23 ++- .../acp-structured-session-adapter-deps.ts | 9 +- ...ructured-session-adapter-lifecycle.test.ts | 15 +- .../acp-structured-session-adapter.test.ts | 12 +- .../acp/acp-structured-session-adapter.ts | 45 +++-- src/main/acp/acp-structured-session.ts | 15 +- src/main/acp/acp-structured-starts.ts | 53 ++--- src/main/acp/acp-structured-steer.test.ts | 34 +++- src/main/acp/acp-structured-turns.ts | 41 +++- .../structured-agent-runtime-registrations.ts | 4 +- .../structured-agent-session-runtime.ts | 3 - 22 files changed, 472 insertions(+), 402 deletions(-) delete mode 100644 src/main/acp/acp-structured-child.test.ts delete mode 100644 src/main/acp/acp-structured-child.ts create mode 100644 src/main/acp/acp-structured-connection.ts diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 062eb6960e12..5685b681bb38 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -1,7 +1,7 @@ -// Making a reservation real for an ACP agent: spawn the child, record it before any handshake, -// initialize with the client's file system and terminals off, then reattach the session this chat -// proved with `session/load` (`session/resume` only for an agent that cannot load) or start a new -// one; a saved session the agent cannot reopen is replaced by a new one, with a warning row. The +// Making a reservation real for an ACP agent: open its connection (which spawns and owns the +// process), record it before any handshake, initialize with the client's file system and terminals +// off, then reattach the session this chat proved with `session/load` (`session/resume` only for an +// agent that cannot load) or start a new one; a saved session the agent cannot reopen is replaced by a new one, with a warning row. The // journal already holds a reattached chat, so whatever the agent sends while it reattaches is not // written, except context usage. The handshake has no time bound: the acquire's abort signal // (Close, Stop, quit) stops it at any point. @@ -28,8 +28,8 @@ import { acpReopenTakeover, acpSessionNotRestoredRow } from './acp-session-reopen-failure' -import { AcpSessionRuntime, type AcpSessionEvent } from './acp-session-runtime' -import type { AcpStructuredChild } from './acp-structured-child' +import type { AcpSessionEvent } from './acp-session-runtime' +import type { AcpStructuredConnection } from './acp-structured-connection' import { ACP_HANDLE_TRANSPORT } from './acp-structured-agent-definitions' import { AcpStructuredLane } from './acp-structured-lane' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' @@ -40,10 +40,7 @@ import { routeAcpSessionEvent, type AcpStructuredSession } from './acp-structured-session' -import { - ACP_CANCEL_TIMEOUT_MS, - type AcpStructuredSessionAdapterDeps -} from './acp-structured-session-adapter-deps' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' import { AcpStructuredTurns, type AcpStructuredTurnsDeps } from './acp-structured-turns' import { RequestPermissionResponseSchema } from './generated/acp-protocol.generated' @@ -57,13 +54,13 @@ export async function acquireAcpStructuredSession(input: { acquire: StructuredAgentSessionAcquireInput deps: AcpStructuredSessionAdapterDeps generation: string - /** The acquire was aborted; checked until the spawn, after which `track` hands it the child. */ + /** The acquire was aborted; checked until the spawn, after which `track` hands it the connection. */ abandoned: () => boolean - /** Registers the child so a close during the acquire can stop it. */ - track: (child: AcpStructuredChild) => void - /** The child's exit, observed while or after the session exists. */ + /** Registers the connection so a close during the acquire can stop it. */ + track: (connection: AcpStructuredConnection) => void + /** The process's exit, observed while or after the session exists. */ onExit: (session: AcpStructuredSession | null) => void - /** The connection broke with the child perhaps still running. Null while starting: then the + /** The protocol broke with the process perhaps still running. Null while starting: then the * start itself fails. */ onConnectionLost: (session: AcpStructuredSession | null, error: Error) => void onSettled: AcpStructuredTurnsDeps['settle'] @@ -92,17 +89,6 @@ export async function acquireAcpStructuredSession(input: { throw new AgentSessionPreSpawnError(error) }) closedBeforeSpawn() - const child = deps.spawnChild({ - command: launch.command, - args: launch.args, - cwd: launch.cwd, - env: { - ...structuredSessionChildIdentityEnv(sessionId, launch.env), - [PROVIDER_SPAWN_TOKEN_ENV]: acquire.spawnToken - }, - envToDelete: ACP_CHILD_ENV_TO_DELETE - }) - input.track(child) let session: AcpStructuredSession | null = null // A slot rather than a `let`: closures read it, and control-flow narrowing cannot see them write. const slot: { lane: AcpStructuredLane | null; reattaching: boolean } = { @@ -112,7 +98,6 @@ export async function acquireAcpStructuredSession(input: { const options = new AcpStructuredOptions() const prompts = new AcpStructuredPrompts(() => slot.lane) const early: (() => void)[] = [] - const connection = { closed: false } const whenLane = (deliver: () => void): void => { if (slot.lane) { deliver() @@ -120,74 +105,70 @@ export async function acquireAcpStructuredSession(input: { early.push(deliver) } } - const runtime = new AcpSessionRuntime(child.stdout, child.stdin, { - clientInfo: { name: 'orca', version: '1' }, - cancelTimeoutMs: deps.cancelTimeoutMs ?? ACP_CANCEL_TIMEOUT_MS, - onPermission: (request, context) => { - if (!session?.turns.running) { - // No prompt of Orca's runs (a turn the agent began itself): nobody is there to ask. - return { outcome: { outcome: 'cancelled' } } - } - if (launch.fullAccess) { - // Full access: Orca answers yes for the person, as the agent's own bypass flag would. - const allow = request.options.find((option) => option.kind === 'allow_once') - if (allow) { - return { outcome: { outcome: 'selected', optionId: allow.optionId } } - } - } - return prompts - .handle('session/request_permission', request, context) - .then((reply) => RequestPermissionResponseSchema.parse(reply)) + const connection = deps.connect( + { + command: launch.command, + args: launch.args, + cwd: launch.cwd, + env: { + ...structuredSessionChildIdentityEnv(sessionId, launch.env), + [PROVIDER_SPAWN_TOKEN_ENV]: acquire.spawnToken + }, + envToDelete: ACP_CHILD_ENV_TO_DELETE }, - onRequest: (method, params, context) => prompts.handle(method, params, context), - onExtensionNotification: (method, params) => - whenLane(() => - slot.lane?.apply( - slot.lane.translator.notification( - method, - slot.reattaching ? asReattachHistory(params) : params, - now() + { + clientInfo: { name: 'orca', version: '1' }, + onPermission: (request, context) => { + if (!session?.turns.running) { + // No prompt of Orca's runs (a turn the agent began itself): nobody is there to ask. + return { outcome: { outcome: 'cancelled' } } + } + if (launch.fullAccess) { + // Full access: Orca answers yes for the person, as the agent's own bypass flag would. + const allow = request.options.find((option) => option.kind === 'allow_once') + if (allow) { + return { outcome: { outcome: 'selected', optionId: allow.optionId } } + } + } + return prompts + .handle('session/request_permission', request, context) + .then((reply) => RequestPermissionResponseSchema.parse(reply)) + }, + onRequest: (method, params, context) => prompts.handle(method, params, context), + onExtensionNotification: (method, params) => + whenLane(() => + slot.lane?.apply( + slot.lane.translator.notification( + method, + slot.reattaching ? asReattachHistory(params) : params, + now() + ) ) - ) - ), - onDiagnostic: (message) => - deps.logger?.warn('ACP agent protocol diagnostic', { - scope: 'acp-diagnostic', - sessionId, - message - }), - onClose: (error) => { - connection.closed = true - // An agent that ended its stdout but can still be written to may still run: Stop or its exit - // ends it. Any other close (a broken stdin, or Orca's own) loses the agent. - const outputOnlyEnded = - child.stdout.readableEnded && child.stdin.writable && !child.stdin.destroyed - if (!outputOnlyEnded) { - input.onConnectionLost(session, error) - } + ), + onDiagnostic: (message) => + deps.logger?.warn('ACP agent protocol diagnostic', { + scope: 'acp-diagnostic', + sessionId, + message + }), + // The protocol broke while the process may still run; the connection is already ending it. + // Its stdout ending alone is not this: the agent may still answer Stop's cancel or exit. + onClose: (error) => input.onConnectionLost(session, error), + // A crash usually ends stdout first; the session's end still reads the agent's last words at + // this proven exit. + onExit: () => input.onExit(session) } - }) - // An abort fails whatever the agent left unanswered at once, as a kill does, whether or not the - // child's exit is proven yet. - const abandon = () => - runtime.close(new Error(`${acpAgentName(spec.agent)} was closed while starting`)) - acquire.signal?.addEventListener('abort', abandon, { once: true }) - if (acquire.signal?.aborted) { - abandon() - } - runtime.subscribe((event: AcpSessionEvent) => + ) + // From here an abort closes this connection (the start's one canceller): whatever the agent left + // unanswered fails at once and the process ends, whether or not its exit is proven yet. + input.track(connection) + connection.subscribe((event: AcpSessionEvent) => whenLane( () => slot.lane && routeAcpSessionEvent({ lane: slot.lane, options }, event, now(), slot.reattaching) ) ) - child.onExit(() => { - // A crash usually ends stdout first, so the connection's loss may already have closed the - // journal; the session's end still reads the agent's last words at this proven exit. - input.onExit(session) - runtime.close(new Error(child.stderrTail() || `${spec.command} exited`)) - }) const identity = providerSpawnedProcessIdentity( acquire, `${spec.agent} ACP agent`, @@ -216,25 +197,26 @@ export async function acquireAcpStructuredSession(input: { } return lane } - await child.spawned - if (child.pid === undefined) { + await connection.spawned + const pid = connection.pid + if (pid === undefined) { throw new AgentSessionPreSpawnError( - new Error(child.stderrTail() || `${launch.command} could not be started`) + new Error(connection.stderrTail() || `${launch.command} could not be started`) ) } - await identity.onSpawned(child.pid) + await identity.onSpawned(pid) const agentName = acpAgentName(spec.agent) try { - await runtime.initialize() + await connection.initialize() const resume = launch.resume - let started: Awaited> | null = null + let started: Awaited> | null = null let liveLane: AcpStructuredLane | null = null let takeover: ReturnType | null = null if (resume) { const attaching = makeLane(resume.sessionId, true) liveLane = attaching try { - started = await runtime.start({ + started = await connection.start({ cwd: launch.cwd, mcpServers: [], sessionId: resume.sessionId @@ -254,13 +236,13 @@ export async function acquireAcpStructuredSession(input: { if (!started || !liveLane) { liveLane?.dispose() slot.lane = null - started = await runtime.start({ cwd: launch.cwd, mcpServers: [] }) + started = await connection.start({ cwd: launch.cwd, mcpServers: [] }) liveLane = makeLane(started.sessionId) } options.adoptSession(started.response) liveLane.apply(liveLane.translator.contextModels(started.response.models, now())) - const restoreSkipped = await restoreAcpSessionOptions(runtime, options, acquire.options) - const process = await identity.read(child.pid) + const restoreSkipped = await restoreAcpSessionOptions(connection, options, acquire.options) + const process = await identity.read(pid) const link: AgentSessionProviderHandleLink = { linkId: deps.mintLinkId?.() ?? `${spec.agent}-${acquire.fence}-${started.sessionId}`.slice(0, 128), @@ -275,13 +257,13 @@ export async function acquireAcpStructuredSession(input: { fence: acquire.fence, acquisitionGeneration: generation, spec, - child, - runtime, + connection, lane: liveLane, prompts, options, turns: new AcpStructuredTurns({ - runtime, + connection, + withdrawRequests: () => prompts.withdrawAll(), lane: liveLane, agentName, now, @@ -295,17 +277,17 @@ export async function acquireAcpStructuredSession(input: { } const reading = liveLane const unbind = events?.bindReadingControl?.({ - pauseReading: () => child.stdout.pause(), + pauseReading: () => connection.pauseReading(), resumeReading: () => { - child.stdout.resume() + connection.resumeReading() reading.retry() } }) if (unbind) { session.unbindReadingControl = unbind } - if (child.exited || connection.closed) { - throw new Error(child.stderrTail() || `${spec.command} exited while starting`) + if (connection.exited || connection.closed) { + throw new Error(connection.stderrTail() || `${spec.command} exited while starting`) } if (takeover?.replaces) { liveLane.apply(acpSessionNotRestoredRow(started.sessionId, agentName)) @@ -321,7 +303,5 @@ export async function acquireAcpStructuredSession(input: { ) } throw error - } finally { - acquire.signal?.removeEventListener('abort', abandon) } } diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index f39b1bf22a2c..e39ade6c7b97 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -1,8 +1,9 @@ -// A scripted ACP agent behind the real adapter: a fake child over in-memory stdio, the real +// A scripted ACP agent behind the real adapter: a fake connection over in-memory stdio, the real // protocol runtime, translator and assembler, and a real on-disk journal to read back. import { vi } from 'vitest' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { ProviderProcessExit } from '../provider-process/managed-provider-process' import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { @@ -12,7 +13,10 @@ import { } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { acpLaunchSpecFor } from './acp-launch-specs' import { AcpScriptedAgent, tick, type FakeFrame } from './acp-scripted-agent.test-support' -import type { AcpStructuredChild } from './acp-structured-child' +import type { AcpAgentConnectionOptions } from './acp-agent-connection' +import { AcpConnectionClosedError } from './acp-errors' +import { AcpSessionRuntime } from './acp-session-runtime' +import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' import { AcpStructuredSessionAdapter } from './acp-structured-session-adapter' import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' @@ -46,54 +50,91 @@ export const GROK_CONFIG_OPTIONS = [ } ] -export class FakeAcpChild implements AcpStructuredChild { - readonly agent = new AcpScriptedAgent() +/** A scripted agent behind the connection surface: the real protocol runtime over in-memory stdio, + * with the process lifecycle `AcpAgentConnection` gives it (stdout EOF is not exit; the exit closes + * the protocol with the agent's last words; a protocol failure while it runs is `onClose`). */ +export class FakeAcpChild extends AcpSessionRuntime implements AcpStructuredConnection { + readonly agent: AcpScriptedAgent readonly pid: number | undefined = PID readonly spawned = Promise.resolve() stderr = '' - treeUnproven = false - private listeners: (() => void)[] = [] + processTreeUnproven = false + private exitListeners: ((exit: ProviderProcessExit) => void)[] = [] private gone = false + private closing = false + private lostWith: Error | undefined closes = 0 - constructor(readonly launch: ProviderProcessLaunch) {} - - get stdout() { - return this.agent.stdout - } - get stdin() { - return this.agent.stdin + constructor( + readonly launch: ProviderProcessLaunch, + private readonly connectionOptions: AcpAgentConnectionOptions, + agent = new AcpScriptedAgent() + ) { + const lifecycle: { child: FakeAcpChild | null } = { child: null } + super(agent.stdout, agent.stdin, { + ...connectionOptions, + peer: { ...connectionOptions.peer, closeOnInputEnd: false }, + onClose: (error) => lifecycle.child?.lost(error) + }) + lifecycle.child = this + this.agent = agent } + get exited() { return this.gone } - onExit(listener: () => void): void { + onExit(listener: (exit: ProviderProcessExit) => void): void { if (this.gone) { - listener() + listener(FAKE_EXIT) } else { - this.listeners.push(listener) + this.exitListeners.push(listener) } } stderrTail(): string { return this.stderr } - async close(): Promise { - this.closes += 1 + pauseReading(): void { + this.agent.stdout.pause() + } + resumeReading(): void { + this.agent.stdout.resume() + } + /** What a close proves once the protocol is closed; replace it to leave the exit unproven. */ + proveClose = async (): Promise => { this.exit() return true } - /** The agent process ends on its own. */ + override close(error?: Error): Promise { + this.closes += 1 + this.closing ||= !this.gone + super.close(error) + return this.proveClose() + } + /** The agent process ends on its own (or Orca's close landed). */ exit(): void { if (this.gone) { return } this.gone = true - for (const listener of this.listeners.splice(0)) { - listener() + const error = + this.lostWith ?? new AcpConnectionClosedError(this.stderr || `${this.launch.command} exited`) + super.close(error) + this.connectionOptions.onExit?.(error, { expected: this.closing, exit: FAKE_EXIT }) + for (const listener of this.exitListeners.splice(0)) { + listener(FAKE_EXIT) + } + } + private lost(error: Error): void { + this.lostWith ??= error + if (this.closing || this.gone) { + return } + this.connectionOptions.onClose?.(error) } } +const FAKE_EXIT: ProviderProcessExit = { code: 0, signal: null, processless: false } + export type AcpAdapterRig = { rig: ProviderTimelineRig adapter: AcpStructuredSessionAdapter @@ -138,9 +179,9 @@ export async function openAcpAdapterRig( resume: null, ...options.launch }), - spawnChild: (launch) => { + connect: (launch, connectionOptions) => { spawned.push('spawn') - const child = new FakeAcpChild(launch) + const child = new FakeAcpChild(launch, connectionOptions) current = child const { agent } = child agent.on('initialize', (frame) => { @@ -164,7 +205,6 @@ export async function openAcpAdapterRig( onDispatchSettledLate: (settlement) => settled.push(settlement), mintGeneration: () => 'gen-acp', now: () => 5_000, - cancelTimeoutMs: 1_000, ...options.deps }) const frames = () => current?.agent.frames ?? [] diff --git a/src/main/acp/acp-structured-broken-stream.test.ts b/src/main/acp/acp-structured-broken-stream.test.ts index 26e45854d9e9..5b2d6bf1e275 100644 --- a/src/main/acp/acp-structured-broken-stream.test.ts +++ b/src/main/acp/acp-structured-broken-stream.test.ts @@ -1,6 +1,7 @@ -// A real child (plain Node, never an agent CLI) that stops talking mid-turn by closing its stdout -// while the process itself keeps running: nothing exited and Orca can still write to it, so, as in -// the common pattern, the turn runs on until a Stop or close ends the agent. +// Real children (plain Node, never an agent CLI) behind the real connection. One stops talking +// mid-turn by closing its stdout while the process keeps running: nothing exited and Orca can still +// write to it, so, as in the common pattern, the turn runs on until a Stop or close ends the agent. +// The other exits while a process it started still holds its stdout open: the exit alone ends it. import { mkdtempSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -17,14 +18,14 @@ import { PROVIDER_SESSION, sendHello } from './acp-structured-adapter.test-support' -import { spawnAcpStructuredChild } from './acp-structured-child' +import { createAcpAgentConnection } from './acp-agent-connection' afterEach(async () => { await closeProviderTimelineRigs() }) -// Answers the handshake, echoes one reply chunk for the prompt, then closes fd 1 and stays alive. -const CLOSES_STDOUT_MID_TURN = String.raw` +// Answers the handshake and echoes one reply chunk for the prompt, then runs `onPrompt`. +const agentScript = (onPrompt: string): string => String.raw` const fs = require('node:fs') fs.writeFileSync(process.env.ORCA_TEST_PID_FILE, String(process.pid)) const send = (frame) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...frame }) + '\n') @@ -45,12 +46,25 @@ const CLOSES_STDOUT_MID_TURN = String.raw` method: 'session/update', params: { sessionId: frame.params.sessionId, update, _meta: frame.params._meta } }) - process.stdout.write('', () => fs.closeSync(1)) + process.stdout.write('', () => { + ${onPrompt} + }) } } }) setInterval(() => {}, 60000) ` +const CLOSES_STDOUT_MID_TURN = agentScript('fs.closeSync(1)') +// Starts a process that inherits its stdout and outlives it, records that process, then exits. +const EXITS_HOLDING_STDOUT = agentScript(String.raw` + const held = require('node:child_process').spawn( + process.execPath, + ['-e', 'setInterval(() => {}, 60000)'], + { stdio: ['ignore', 'inherit', 'ignore'] } + ) + fs.writeFileSync(process.env.ORCA_TEST_PID_FILE + '.held', String(held.pid)) + process.exit(3) +`) function alive(pid: number): boolean { try { @@ -66,19 +80,22 @@ describe('ACP agent that closes its stdout but keeps running', () => { const pidFile = join(mkdtempSync(join(tmpdir(), 'orca-acp-broken-stream-')), 'pid') const rig = await openAcpAdapterRig({ deps: { - spawnChild: () => - spawnAcpStructuredChild({ - command: process.execPath, - args: ['-e', CLOSES_STDOUT_MID_TURN], - cwd: process.cwd(), - env: { - ORCA_TEST_PID_FILE: pidFile, - ORCA_TEST_SESSION: JSON.stringify({ - sessionId: PROVIDER_SESSION, - configOptions: GROK_CONFIG_OPTIONS - }) - } - }) + connect: (_launch, options) => + createAcpAgentConnection( + { + command: process.execPath, + args: ['-e', CLOSES_STDOUT_MID_TURN], + cwd: process.cwd(), + env: { + ORCA_TEST_PID_FILE: pidFile, + ORCA_TEST_SESSION: JSON.stringify({ + sessionId: PROVIDER_SESSION, + configOptions: GROK_CONFIG_OPTIONS + }) + } + }, + options + ) } }) onTestFinished(async () => { @@ -103,3 +120,43 @@ describe('ACP agent that closes its stdout but keeps running', () => { expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) }, 30_000) }) + +describe('ACP agent that exits while a process it started holds its stdout open', () => { + it('ends the session at the exit, with the turn interrupted', async () => { + const pidFile = join(mkdtempSync(join(tmpdir(), 'orca-acp-held-stdout-')), 'pid') + const rig = await openAcpAdapterRig({ + deps: { + connect: (_launch, options) => + createAcpAgentConnection( + { + command: process.execPath, + args: ['-e', EXITS_HOLDING_STDOUT], + cwd: process.cwd(), + env: { + ORCA_TEST_PID_FILE: pidFile, + ORCA_TEST_SESSION: JSON.stringify({ + sessionId: PROVIDER_SESSION, + configOptions: GROK_CONFIG_OPTIONS + }) + } + }, + options + ) + } + }) + onTestFinished(async () => { + await rig.adapter.closeAll().catch(() => {}) + const held = Number(readFileSync(`${pidFile}.held`, 'utf8')) + if (alive(held)) { + process.kill(held) + } + }) + await rig.acquire() + await sendHello(rig, 'held') + await vi.waitFor(() => expect(rig.lifecycle).toHaveLength(1), { timeout: 10_000, interval: 20 }) + expect(rig.lifecycle[0]).toMatchObject({ type: 'ended', cause: 'unexpected-exit' }) + expect(alive(Number(readFileSync(pidFile, 'utf8')))).toBe(false) + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.at(-1)).toMatchObject({ state: 'interrupted' }) + }, 30_000) +}) diff --git a/src/main/acp/acp-structured-child.test.ts b/src/main/acp/acp-structured-child.test.ts deleted file mode 100644 index 2df3e7c1f91e..000000000000 --- a/src/main/acp/acp-structured-child.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -// A real child process (plain Node, never an agent CLI) behind the ACP child wrapper. - -import { describe, expect, it } from 'vitest' -import { spawnAcpStructuredChild } from './acp-structured-child' - -// Echoes stdin lines back, writes to stderr, and exits once stdin closes. -const ECHO = [ - "process.stderr.write('agent log\\n')", - "process.stdin.on('data', (chunk) => process.stdout.write(chunk))", - "process.stdin.on('end', () => process.exit(0))" -].join(';') - -function launch(script: string) { - return { command: process.execPath, args: ['-e', script], cwd: process.cwd() } -} - -describe('ACP structured child', () => { - it('carries stdio, keeps the stderr tail, and proves its exit on close', async () => { - const child = spawnAcpStructuredChild(launch(ECHO)) - await child.spawned - expect(child.pid).toEqual(expect.any(Number)) - const echoed = new Promise((resolve) => - child.stdout.once('data', (chunk: Buffer) => resolve(chunk.toString('utf8'))) - ) - child.stdin.write('{"jsonrpc":"2.0"}\n') - expect(await echoed).toBe('{"jsonrpc":"2.0"}\n') - let exits = 0 - child.onExit(() => { - exits += 1 - }) - await expect(child.close()).resolves.toBe(true) - expect(child.exited).toBe(true) - expect(exits).toBe(1) - expect(child.stderrTail()).toContain('agent log') - await expect(child.close()).resolves.toBe(true) - }, 20_000) - - it('reports an exit nobody asked for', async () => { - const child = spawnAcpStructuredChild(launch('process.exit(3)')) - await new Promise((resolve) => child.onExit(resolve)) - expect(child.exited).toBe(true) - }, 20_000) - - // POSIX starts a supervisor that then fails to exec; Windows starts nothing at all. - it('ends a binary that cannot start, with or without a process to name', async () => { - const child = spawnAcpStructuredChild({ - command: '/nonexistent/orca-acp-test-binary', - args: [], - cwd: process.cwd() - }) - await child.spawned - if (child.pid !== undefined) { - await new Promise((resolve) => child.onExit(resolve)) - expect(child.exited).toBe(true) - } - await expect(child.close()).resolves.toBe(true) - }, 20_000) -}) diff --git a/src/main/acp/acp-structured-child.ts b/src/main/acp/acp-structured-child.ts deleted file mode 100644 index fa5c7baf1eca..000000000000 --- a/src/main/acp/acp-structured-child.ts +++ /dev/null @@ -1,86 +0,0 @@ -// The ACP agent's process, as the adapter needs it: its stdio for the protocol, its observed exit, -// and a close that answers true only once that exit is proven. Supervision, the process-tree -// teardown and Windows-safe spawning are the shared provider lifecycle's. - -import type { Readable, Writable } from 'node:stream' -import { spawnProcess } from '../../shared/child-process/run-process' -import { spawnManagedProviderProcess } from '../provider-process/managed-provider-process' -import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' - -export type AcpStructuredChild = { - readonly pid: number | undefined - /** Agent → Orca. */ - readonly stdout: Readable - /** Orca → agent. */ - readonly stdin: Writable - readonly exited: boolean - /** Resolves once the spawn either produced a process or failed to. */ - readonly spawned: Promise - onExit(listener: () => void): void - /** The agent's own last words, for a failure a person reads. */ - stderrTail(): string - /** True only once the exit is proven. */ - close(): Promise - /** The root exited but the close could not prove its process tree gone. */ - readonly treeUnproven: boolean -} - -export type SpawnAcpStructuredChild = (launch: ProviderProcessLaunch) => AcpStructuredChild - -export function spawnAcpStructuredChild( - launch: ProviderProcessLaunch, - spawnImpl: typeof spawnProcess = spawnProcess -): AcpStructuredChild { - // The managed process owns the close ladder, its root-only defaults, and the stderr tail. - const managed = spawnManagedProviderProcess(launch, { spawnImpl, site: 'acp-agent-teardown' }) - const { child } = managed - // A broken pipe surfaces as the exit the managed process observes; never as an uncaught error. - child.stdin.on('error', () => {}) - const spawned = new Promise((resolve) => { - if (child.pid !== undefined) { - resolve() - return - } - child.once('spawn', () => resolve()) - child.once('error', () => resolve()) - }) - return { - get pid() { - return child.pid - }, - stdout: child.stdout, - stdin: child.stdin, - get exited() { - return managed.rootVerdict === 'exited' - }, - spawned, - onExit: (listener) => managed.onExit(() => listener()), - stderrTail: () => managed.stderrTail().trim(), - close: async () => (await managed.close()).root === 'exited', - get treeUnproven() { - const result = managed.lastCloseResult - return result?.root === 'exited' && result.tree === 'unverifiable' - } - } -} - -/** Resolves once `child` exits, `signal` aborts, or `ms` pass, whichever is first. */ -export function waitForAcpChildExit( - child: AcpStructuredChild, - ms: number, - signal: AbortSignal -): Promise { - if (child.exited || signal.aborted) { - return Promise.resolve() - } - return new Promise((resolve) => { - const done = (): void => { - clearTimeout(timer) - signal.removeEventListener('abort', done) - resolve() - } - const timer = setTimeout(done, ms) - signal.addEventListener('abort', done, { once: true }) - child.onExit(done) - }) -} diff --git a/src/main/acp/acp-structured-connection.ts b/src/main/acp/acp-structured-connection.ts new file mode 100644 index 000000000000..1ac06b7620d8 --- /dev/null +++ b/src/main/acp/acp-structured-connection.ts @@ -0,0 +1,54 @@ +// The ACP agent as the adapter holds it: one connection that owns both the agent's process and its +// protocol (`createAcpAgentConnection`). Supervision, the process-tree teardown, Windows-safe +// spawning and stdio are the connection's; turns, Stop and steering are the adapter's. + +import type { AcpAgentConnection, AcpAgentConnectionOptions } from './acp-agent-connection' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' + +/** What the adapter uses of a connection; tests stand in a scripted agent behind the same surface. */ +export type AcpStructuredConnection = Pick< + AcpAgentConnection, + | 'initialize' + | 'start' + | 'prompt' + | 'cancel' + | 'setConfigOption' + | 'setModel' + | 'subscribe' + | 'closed' + | 'spawned' + | 'pid' + | 'exited' + | 'stderrTail' + | 'onExit' + | 'pauseReading' + | 'resumeReading' + | 'close' + | 'processTreeUnproven' +> + +export type ConnectAcpAgent = ( + launch: ProviderProcessLaunch, + options: AcpAgentConnectionOptions +) => AcpStructuredConnection + +/** Resolves once `connection`'s process exits, `signal` aborts, or `ms` pass, whichever is first. */ +export function waitForAcpExit( + connection: AcpStructuredConnection, + ms: number, + signal: AbortSignal +): Promise { + if (connection.exited || signal.aborted) { + return Promise.resolve() + } + return new Promise((resolve) => { + const done = (): void => { + clearTimeout(timer) + signal.removeEventListener('abort', done) + resolve() + } + const timer = setTimeout(done, ms) + signal.addEventListener('abort', done, { once: true }) + connection.onExit(done) + }) +} diff --git a/src/main/acp/acp-structured-electron-free.test.ts b/src/main/acp/acp-structured-electron-free.test.ts index fbebb5d625bd..0a63542a1997 100644 --- a/src/main/acp/acp-structured-electron-free.test.ts +++ b/src/main/acp/acp-structured-electron-free.test.ts @@ -11,7 +11,7 @@ describe('ACP structured lane', () => { entryPoints: [ resolve(__dirname, 'acp-structured-session-adapter.ts'), resolve(__dirname, 'acp-structured-launch-resolution.ts'), - resolve(__dirname, 'acp-structured-child.ts') + resolve(__dirname, 'acp-agent-connection.ts') ], bundle: true, write: false, diff --git a/src/main/acp/acp-structured-host-crash.test.ts b/src/main/acp/acp-structured-host-crash.test.ts index d0090afc8f82..a656ac08de4c 100644 --- a/src/main/acp/acp-structured-host-crash.test.ts +++ b/src/main/acp/acp-structured-host-crash.test.ts @@ -90,7 +90,7 @@ describe('a Grok crash whose pipes close before its exit is seen', () => { const rig = await midReply() // The child outlives its stream until Orca's close of it is proven. let exitNow: () => void = () => undefined - rig.child.close = () => + rig.child.proveClose = () => new Promise((resolve) => { exitNow = () => { rig.child.exit() diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index a1fd2dbd342c..a1486cd3263e 100644 --- a/src/main/acp/acp-structured-host-lifecycle.test.ts +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -124,8 +124,7 @@ describe('a Grok crash whose exit is not proven yet', () => { child.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'partial')) await rig.settle() let proves = false - child.close = async () => { - child.closes += 1 + child.proveClose = async () => { if (proves) { child.exit() } @@ -177,15 +176,15 @@ describe('closing or stopping a Grok chat while it starts', () => { const attaching = host.attach(CALLER, attachParams()).catch((error: unknown) => error) await rig.frame('initialize') const child = rig.child() - child.close = vi.fn(async () => false) + child.proveClose = vi.fn(async () => false) await host.close(SESSION, 'user-close') expect(await attaching).toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) - child.close = vi.fn(async () => { + child.proveClose = vi.fn(async () => { child.exit() return true }) await host.close(SESSION, 'user-close') - expect(child.close).not.toHaveBeenCalled() + expect(child.proveClose).not.toHaveBeenCalled() expect(child.exited).toBe(false) // As Claude's: the next start asks the child again before it spawns another. await host.send(CALLER, { diff --git a/src/main/acp/acp-structured-host-stop.test.ts b/src/main/acp/acp-structured-host-stop.test.ts index 7fe35c5b2e71..2e6677390d65 100644 --- a/src/main/acp/acp-structured-host-stop.test.ts +++ b/src/main/acp/acp-structured-host-stop.test.ts @@ -64,6 +64,26 @@ describe('a Grok chat Stop', () => { await host.close(SESSION, 'user-close') }) + it('ends Grok at the grace when Grok never reads the cancel, never waiting on its write', async () => { + const { rig, host, turns } = await openAttachedHostRig({ stopGraceMs: 30 }) + const first = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + first.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'partial')) + await rig.settle() + await host.flushStreamedEvents(SESSION) + // A write that never completes, as a full pipe to a Grok that stopped reading. + let cancels = 0 + first.cancel = () => { + cancels += 1 + return new Promise(() => {}) + } + expect(await stop(host)).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(first.exited).toBe(true)) + expect(cancels).toBe(1) + expect((await turns()).at(-1)).toMatchObject({ state: 'interrupted' }) + }) + it('ends Grok on a Stop of a turn it began itself, once that turn ends', async () => { const { rig, host, turns } = await openAttachedHostRig() const { agent } = rig.child() diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index 751ab76276fa..662c3c98c1a9 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -10,7 +10,7 @@ import type { } from '../../shared/agent-session-wire' import { isAcpStructuredOptionKey } from './acp-structured-agent-definitions' import { AcpRpcError } from './acp-errors' -import type { AcpSessionRuntime } from './acp-session-runtime' +import type { AcpStructuredConnection } from './acp-structured-connection' import { SessionConfigSelectGroupSchema, SessionConfigSelectOptionSchema, @@ -161,7 +161,7 @@ export class AcpStructuredOptions { /** Sends a pick and adopts the agent's answer, even one that lands after the wait gave up: that is * still what the agent runs. The wait fails at `timeoutMs`, or once `signal` aborts. */ export function writeAcpSessionOption( - runtime: Pick, + connection: Pick, options: AcpStructuredOptions, write: AcpOptionWrite, bound: { agent: string; timeoutMs: number; signal?: AbortSignal } @@ -172,10 +172,10 @@ export function writeAcpSessionOption( } const applied = write.method === 'config' - ? runtime + ? connection .setConfigOption(write.configId, write.value) .then((result) => options.adoptConfigOptions(result.configOptions)) - : runtime.setModel(write.modelId).then(() => options.adoptModel(write.modelId)) + : connection.setModel(write.modelId).then(() => options.adoptModel(write.modelId)) return new Promise((resolve, reject) => { const fail = (error: Error): void => { settle() @@ -201,7 +201,7 @@ export function writeAcpSessionOption( /** Re-applies the chat's saved picks to the agent's new session; a pick it refuses is skipped and * reported, never retried. */ export async function restoreAcpSessionOptions( - runtime: Pick, + connection: Pick, options: AcpStructuredOptions, saved: Readonly> | undefined ): Promise { @@ -215,10 +215,10 @@ export async function restoreAcpSessionOptions( try { if (write?.method === 'config') { options.adoptConfigOptions( - (await runtime.setConfigOption(write.configId, value)).configOptions + (await connection.setConfigOption(write.configId, value)).configOptions ) } else if (write?.method === 'model') { - await runtime.setModel(write.modelId) + await connection.setModel(write.modelId) options.adoptModel(write.modelId) } else { skipped.push(key) diff --git a/src/main/acp/acp-structured-prompts.ts b/src/main/acp/acp-structured-prompts.ts index 2157a606f75b..f934ba7f9a27 100644 --- a/src/main/acp/acp-structured-prompts.ts +++ b/src/main/acp/acp-structured-prompts.ts @@ -2,9 +2,10 @@ // JSON-RPC call it is until a person answers it, a Stop cancels it, or the agent withdraws it. // An answer claims the request, commits the journal compare-and-set while the claim is held, and // only then replies, so a second client loses the commit and the agent hears exactly one answer. -// Each request is this module's to answer: once the agent or a Stop cancels it, an unclaimed one is -// answered with the agent's own cancelled reply, and a claimed one still sends the committed answer -// — except a permission, which the protocol answers `cancelled` itself the moment it is cancelled. +// Each request is this module's to answer: once the agent cancels it, or a Stop or steer withdraws +// what is open (`withdrawAll`), an unclaimed one is answered with the agent's own cancelled reply +// and a claimed one still sends the committed answer — except a permission the agent itself +// cancelled, which the protocol answers `cancelled` the moment it is cancelled. import type { AgentSessionPromptResponse } from '../../shared/agent-session-question-answer' import { @@ -23,6 +24,8 @@ type OpenRequest = { permission: boolean signal: AbortSignal claimed: boolean + /** A Stop or steer withdrew it while an answer was being saved: a failed save withdraws it. */ + withdrawing: boolean settle: (reply: unknown) => void } @@ -56,6 +59,7 @@ export class AcpStructuredPrompts { permission: method === 'session/request_permission', signal: context.signal, claimed: false, + withdrawing: false, settle: (reply) => { if (this.open.get(key) === entry) { this.open.delete(key) @@ -100,7 +104,7 @@ export class AcpStructuredPrompts { await input.commit() } catch (error) { entry.claimed = false - if (entry.signal.aborted) { + if (entry.signal.aborted || entry.withdrawing) { this.withdraw(entry) } throw error @@ -112,6 +116,17 @@ export class AcpStructuredPrompts { } } + /** A Stop or steer: the agent hears its own cancelled reply to everything no answer has claimed. */ + withdrawAll(): void { + for (const entry of this.open.values()) { + if (entry.claimed) { + entry.withdrawing = true + } else { + this.withdraw(entry) + } + } + } + private withdraw(entry: OpenRequest): void { entry.settle(entry.presentation.reply(null)) this.lane()?.apply([{ type: 'request.withdrawn', request: entry.key }]) diff --git a/src/main/acp/acp-structured-session-adapter-deps.ts b/src/main/acp/acp-structured-session-adapter-deps.ts index 7cbb863144dd..3f59f4db3e4a 100644 --- a/src/main/acp/acp-structured-session-adapter-deps.ts +++ b/src/main/acp/acp-structured-session-adapter-deps.ts @@ -6,11 +6,9 @@ import type { } from '../../shared/agent-session-journal-types' import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import type { AcpLaunchSpec } from './acp-launch-specs' -import type { SpawnAcpStructuredChild } from './acp-structured-child' +import type { ConnectAcpAgent } from './acp-structured-connection' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' -/** How long a cancel waits for the agent to answer its prompt before the connection closes. */ -export const ACP_CANCEL_TIMEOUT_MS = 10_000 /** How long a Stop, counted from its cancel, lets the agent end its turn before the child goes. */ export const ACP_STOP_GRACE_MS = 4_000 /** How long a model or effort pick waits for the agent's answer, as Claude's and Codex's do. */ @@ -19,7 +17,8 @@ export const ACP_OPTION_WRITE_TIMEOUT_MS = 30_000 export type AcpStructuredSessionAdapterDeps = { spec: AcpLaunchSpec resolveLaunch: (input: { identity: AgentSessionJournalIdentity }) => Promise - spawnChild: SpawnAcpStructuredChild + /** Starts the agent's process and owns its protocol: `createAcpAgentConnection` in production. */ + connect: ConnectAcpAgent readProcessStartTime?: (pid: number) => Promise /** Every exit, expected or not: the host ends that child's record. */ onEvent?: (event: StructuredAgentSessionLifecycleEvent) => void @@ -35,8 +34,6 @@ export type AcpStructuredSessionAdapterDeps = { now?: () => number mintGeneration?: () => string mintLinkId?: () => string - /** Bounds a cancel's wait for the agent to answer its prompt; past it the connection closes. */ - cancelTimeoutMs?: number /** Bounds a Stop's wait for the agent to end its turn; past it the child is closed. */ stopGraceMs?: number /** Bounds a pick's wait for the agent's answer; past it the pick fails. */ diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index 712d288b859b..0ccd54ad88f1 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -164,7 +164,7 @@ describe('ACP connection loss', () => { await sendHello(rig, 'lost') await rig.frame('session/prompt') await sendHello(rig, 'held') - rig.child().stdin.destroy() + rig.child().agent.stdin.destroy() await rig.settle() await waitFor(() => expect(rig.lifecycle).toMatchObject([ @@ -187,7 +187,7 @@ describe('ACP connection loss', () => { const rig = await openAcpAdapterRig() await rig.acquire() rig.child().stderr = 'panic: out of memory' - rig.child().stdout.end() + rig.child().agent.stdout.end() await rig.settle() expect(rig.lifecycle).toEqual([]) rig.child().exit() @@ -204,10 +204,7 @@ describe('ACP connection loss', () => { const rig = await openAcpAdapterRig() await rig.acquire() const child = rig.child() - child.close = async () => { - child.closes += 1 - return false - } + child.proveClose = async () => false child.stderr = 'panic: late' child.agent.close() await rig.settle() @@ -245,7 +242,7 @@ describe('ACP connection loss', () => { await rig.frame('session/prompt') rig.child().agent.notify('session/update', replyChunk('prompt:lost', 'partial')) await rig.settle() - rig.child().stdin.destroy() + rig.child().agent.stdin.destroy() await waitFor(async () => expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'unverifiable' }) ) @@ -274,7 +271,7 @@ describe('ACP connection loss', () => { await rig.frame('session/prompt') rig.child().agent.notify('session/update', replyChunk('prompt:quiet', 'partial')) await rig.settle() - rig.child().stdout.end() + rig.child().agent.stdout.end() await rig.settle() expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'running' }) expect(rig.child().closes).toBe(0) @@ -384,7 +381,7 @@ describe('ACP startup that never answers', () => { const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error) await rig.frame('initialize') const child = rig.child() - child.close = vi.fn(async () => false) + child.proveClose = vi.fn(async () => false) start.abort() expect(await failed).toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) // Every later stop asks the child again, and none answers for it. diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 457ca07cd4ab..7130bca98dd9 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -283,7 +283,7 @@ describe('ACP structured session adapter: requests a Stop cancels', () => { expect(agent.frames.filter((frame) => frame.id === 5)).toHaveLength(1) }) - it('reports a permission answer whose save outlived the Stop as unconfirmed, not given', async () => { + it('sends a permission answer whose save was under way when the Stop landed', async () => { const rig = await openAcpAdapterRig() await rig.acquire() await send(rig, 'send-1') @@ -309,8 +309,12 @@ describe('ACP structured session adapter: requests a Stop cancels', () => { // The Stop lands while the person's answer is being saved. commit: () => rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 }).then(() => {}) }) - await expect(answering).rejects.toThrow(/stopped waiting/) - expect(await permission).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + // The Stop withdraws only what no answer has claimed. + await expect(answering).resolves.toBeUndefined() + expect(await permission).toMatchObject({ + result: { outcome: { outcome: 'selected', optionId: 'allow-once' } } + }) + expect(rig.sent('session/cancel')).toHaveLength(1) }) }) @@ -362,7 +366,7 @@ describe('ACP structured session adapter: close and exit', () => { it('reports a close whose root exited but whose process tree was not proven gone', async () => { const rig = await openAcpAdapterRig() await rig.acquire() - rig.child().treeUnproven = true + rig.child().processTreeUnproven = true await expect(rig.adapter.closeSession(SESSION)).rejects.toBeInstanceOf( AgentSessionAcquisitionRootExitObservedError ) diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 27d4d1d262c5..88f5489166bb 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -28,7 +28,7 @@ import { type AcpStructuredSession } from './acp-structured-session' import { AcpStructuredStarts, type AcpStartAttempt } from './acp-structured-starts' -import { waitForAcpChildExit } from './acp-structured-child' +import { waitForAcpExit } from './acp-structured-connection' import { AcpConnectionClosedError } from './acp-errors' import { acpPromptBlocks } from './acp-structured-turns' import { @@ -78,7 +78,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte deps: this.deps, generation, abandoned: () => attempt.signal.aborted, - track: (child) => this.starts.track(attempt, child), + track: (connection) => this.starts.track(attempt, connection), onExit: (session) => { if (session && this.sessions.get(sessionId) === session) { this.finish(session, this.now()) @@ -95,16 +95,17 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte this.sessions.set(sessionId, session) return acquisition } catch (error) { - const { child } = attempt - if (child && error instanceof AcpConnectionClosedError) { - // A dying agent's stdout ends before its exit is seen: wait (bounded) for that exit, so - // the failure carries its last words, as a running session's end does. - await waitForAcpChildExit(child, this.deps.stopGraceMs ?? ACP_STOP_GRACE_MS, attempt.signal) + const { connection } = attempt + if (connection && error instanceof AcpConnectionClosedError) { + // A protocol that broke before the exit was seen: wait (bounded) for that exit, so the + // failure carries the agent's last words, as a running session's end does. + await waitForAcpExit(connection, this.deps.stopGraceMs ?? ACP_STOP_GRACE_MS, attempt.signal) } // Checked before the close below, which would make any exit look like one Orca asked for. - const exitedOnItsOwn = child?.exited === true && !attempt.signal.aborted - if (child && !(await child.close().catch(() => false))) { - this.starts.retainFailed(sessionId, child) + const exitedOnItsOwn = connection?.exited === true && !attempt.signal.aborted + if (connection && !(await connection.close().catch(() => false))) { + // Kept, so the next start or quit closes this same process again; no second one spawns. + this.starts.retainFailed(sessionId, connection) throw new AgentSessionAcquisitionExitUnprovenError(error) } if (attempt.signal.aborted) { @@ -112,12 +113,14 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte `${acpAgentName(this.deps.spec.agent)} was closed while starting`, { cause: error } ) - throw child ? closed : new AgentSessionPreSpawnError(closed) + throw connection ? closed : new AgentSessionPreSpawnError(closed) } - if (child && exitedOnItsOwn && !isAgentSessionPreSpawnError(error)) { + if (connection && exitedOnItsOwn && !isAgentSessionPreSpawnError(error)) { // The agent's own last words are what a person can act on. throw new AgentSessionAcquisitionExitProvenError( - withObservedProviderExit(new Error(child.stderrTail() || String(error), { cause: error })) + withObservedProviderExit( + new Error(connection.stderrTail() || String(error), { cause: error }) + ) ) } throw error @@ -167,9 +170,11 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte ? { cancelled: true } : { cancelled: false, refusal: { turnNotRunning: true } } } - // Answers every open request cancelled and lets the agent end its turn its own way; the host - // ends the child once that lands or the grace runs out (`awaitStoppedRequestEnd`). - void session.runtime.cancel().catch(() => undefined) + // The agent hears its own cancelled reply to what it asked, then may end its turn its own way; + // the host ends the process once that lands or the grace runs out (`awaitStoppedRequestEnd`), + // never waiting on this write. + session.prompts.withdrawAll() + void session.connection.cancel().catch(() => undefined) return { cancelled: true } } @@ -214,7 +219,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte throw new Error(`${session.spec.agent} offers no session option named ${input.key}`) } // Bounded, and abandoned by a close or Stop: the session's queue waits on it. - await writeAcpSessionOption(session.runtime, session.options, write, { + await writeAcpSessionOption(session.connection, session.options, write, { agent: session.spec.agent, timeoutMs: this.deps.optionWriteTimeoutMs ?? ACP_OPTION_WRITE_TIMEOUT_MS, ...(input.signal ? { signal: input.signal } : {}) @@ -254,9 +259,9 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte /** A requested close: proven by the root's exit; a tree not proven gone is the caller's to report. */ private async close(sessionId: string): Promise { - const child = this.sessions.get(sessionId)?.child + const connection = this.sessions.get(sessionId)?.connection const closed = await this.stop(sessionId, true) - if (closed && child?.treeUnproven) { + if (closed && connection?.processTreeUnproven) { throw new AgentSessionAcquisitionRootExitObservedError( new Error( `${this.deps.spec.agent} ACP agent exited, but its process tree was not proven gone` @@ -286,7 +291,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte session.closeRequested ||= requested session.lane.flush() } - const proven = await session.child.close() + const proven = await session.connection.close() if (proven) { this.finish(session, session.exitObservedAt ?? this.now()) } diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts index a8bd9c22631c..6b9fef556e43 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -1,5 +1,5 @@ -// One live ACP child and what Orca keeps beside it, and how its traffic and its exit reach the -// journal and the host. +// One live ACP agent connection and what Orca keeps beside it, and how its traffic and its +// process's exit reach the journal and the host. import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -8,8 +8,8 @@ import { type StructuredAgentSessionTurnVerdict } from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' import type { AcpLaunchSpec } from './acp-launch-specs' -import type { AcpSessionEvent, AcpSessionRuntime } from './acp-session-runtime' -import type { AcpStructuredChild } from './acp-structured-child' +import type { AcpSessionEvent } from './acp-session-runtime' +import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpStructuredLane } from './acp-structured-lane' import type { AcpStructuredOptions } from './acp-structured-options' import type { AcpStructuredPrompts } from './acp-structured-prompts' @@ -20,8 +20,8 @@ export type AcpStructuredSession = { fence: number acquisitionGeneration: string spec: AcpLaunchSpec - child: AcpStructuredChild - runtime: AcpSessionRuntime + /** The agent's process and its protocol, one owner. */ + connection: AcpStructuredConnection lane: AcpStructuredLane prompts: AcpStructuredPrompts options: AcpStructuredOptions @@ -98,7 +98,6 @@ export function closeAcpSessionJournal( session.lane.flush() session.lane.dispose() session.unbindReadingControl?.() - session.runtime.close(new Error(reason)) } /** The child's proven exit, once: the journal closes if it has not, and the host hears `ended` so @@ -113,7 +112,7 @@ export function endAcpStructuredSession( } session.ended = true session.exitObservedAt = observedAt - const stderr = session.child.stderrTail() + const stderr = session.connection.stderrTail() // Read at the proven exit: a crash usually ends the agent's stdout before its exit is observed, // so the connection's close comes first, but the agent's own last words are what explain it. const reason = session.closeRequested diff --git a/src/main/acp/acp-structured-starts.ts b/src/main/acp/acp-structured-starts.ts index 26005dc7cc92..b87911174ca1 100644 --- a/src/main/acp/acp-structured-starts.ts +++ b/src/main/acp/acp-structured-starts.ts @@ -1,63 +1,64 @@ // The adapter's starts: each acquire under way, stopped when the host aborts its signal (a close, -// a Stop that must not wait behind it, or quit), and each failed start's child until its exit is -// proven, so the next start or quit retries it instead of answering for a child it no longer knows. +// a Stop that must not wait behind it, or quit), and each failed start's connection until its +// process's exit is proven, so the next start or quit retries that same connection's close instead +// of answering for a process it no longer knows (and never spawns a second one meanwhile). -import type { AcpStructuredChild } from './acp-structured-child' +import type { AcpStructuredConnection } from './acp-structured-connection' export type AcpStartAttempt = { /** The host's: the start's one canceller. */ readonly signal: AbortSignal - child: AcpStructuredChild | null + connection: AcpStructuredConnection | null /** What the signal's abort does while the start runs; detached once it ends. */ - readonly stopChild: () => void + readonly stopConnection: () => void } export class AcpStructuredStarts { - private readonly failed = new Map() + private readonly failed = new Map() /** Registered before anything awaits, so an abort from here on stops this start. */ begin(signal: AbortSignal | undefined): AcpStartAttempt { const attempt: AcpStartAttempt = { signal: signal ?? new AbortController().signal, - child: null, - stopChild: () => void attempt.child?.close().catch(() => false) + connection: null, + stopConnection: () => void attempt.connection?.close().catch(() => false) } - attempt.signal.addEventListener('abort', attempt.stopChild, { once: true }) + attempt.signal.addEventListener('abort', attempt.stopConnection, { once: true }) return attempt } - /** The start has its child; one already aborted goes as soon as it exists. */ - track(attempt: AcpStartAttempt, child: AcpStructuredChild): void { - attempt.child = child + /** The start has its connection; one already aborted goes as soon as it exists. */ + track(attempt: AcpStartAttempt, connection: AcpStructuredConnection): void { + attempt.connection = connection if (attempt.signal.aborted) { - void child.close().catch(() => false) + void connection.close().catch(() => false) } } - /** A child the start handed over is the session's: an abort after this goes through its stop, - * which knows the close was asked for, not this listener, which would read as a crash. */ + /** A connection the start handed over is the session's: an abort after this goes through its + * stop, which knows the close was asked for, not this listener, which would read as a crash. */ end(attempt: AcpStartAttempt): void { - attempt.signal.removeEventListener('abort', attempt.stopChild) + attempt.signal.removeEventListener('abort', attempt.stopConnection) } - /** A failed start whose child is not proven gone keeps it until its exit is. */ - retainFailed(sessionId: string, child: AcpStructuredChild): void { - this.failed.set(sessionId, child) - child.onExit(() => { - if (this.failed.get(sessionId) === child) { + /** A failed start whose process is not proven gone keeps its connection until its exit is. */ + retainFailed(sessionId: string, connection: AcpStructuredConnection): void { + this.failed.set(sessionId, connection) + connection.onExit(() => { + if (this.failed.get(sessionId) === connection) { this.failed.delete(sessionId) } }) } - /** Asks a failed start's child to stop again: true once none is left unproven. */ + /** Asks a failed start's connection to close again: true once none is left unproven. */ async stopFailed(sessionId: string): Promise { - const child = this.failed.get(sessionId) - if (!child) { + const connection = this.failed.get(sessionId) + if (!connection) { return true } - const proven = await child.close().catch(() => false) - if (proven && this.failed.get(sessionId) === child) { + const proven = await connection.close().catch(() => false) + if (proven && this.failed.get(sessionId) === connection) { this.failed.delete(sessionId) } return proven diff --git a/src/main/acp/acp-structured-steer.test.ts b/src/main/acp/acp-structured-steer.test.ts index c9586a12cf48..19a6ee5cf5e1 100644 --- a/src/main/acp/acp-structured-steer.test.ts +++ b/src/main/acp/acp-structured-steer.test.ts @@ -106,6 +106,32 @@ describe('a send while a Grok prompt runs', () => { expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['first', 'steer-1']) }) + it('asks again on the next steer when the first cancel was never written', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + await rig.frame('session/prompt') + await rig.settle() + const child = rig.child() + const cancel = child.cancel.bind(child) + let failNext = true + child.cancel = (options) => { + if (failNext) { + failNext = false + return Promise.reject(new Error('write failed')) + } + return cancel(options) + } + await sendHello(rig, 'steer-1') + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(0) + await sendHello(rig, 'steer-2') + await sendHello(rig, 'steer-3') + await rig.settle() + // The failed write freed the running prompt's one cancel; the next steer used it. + expect(rig.sent('session/cancel')).toHaveLength(1) + }) + it('withdraws a steer a Stop reaches before its cancel lands', async () => { const rig = await openAcpAdapterRig() await rig.acquire() @@ -130,7 +156,7 @@ describe('a send while a Grok prompt runs', () => { }) it('waits as long as Grok takes to answer a steer cancel, and a Stop then still ends it', async () => { - const rig = await openAcpAdapterRig({ deps: { cancelTimeoutMs: 30 } }) + const rig = await openAcpAdapterRig() await rig.acquire() await sendHello(rig, 'first') await rig.frame('session/prompt') @@ -143,7 +169,7 @@ describe('a send while a Grok prompt runs', () => { expect(rig.lifecycle).toEqual([]) expect(rig.child().closes).toBe(0) expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['first']) - // The Stop's own cancel keeps its bound. + // A Stop sends its own cancel; the host's grace, not the cancel, ends Grok. await rig.adapter.cancelTurn({ sessionId: ADAPTER_SESSION, fence: 1 }) expect(rig.sent('session/cancel')).toHaveLength(2) expect(rig.settled).toEqual( @@ -179,7 +205,7 @@ describe('a send while a turn Grok began itself runs', () => { }) it("cuts Grok's turn on a second send and runs both messages, never ending Grok", async () => { - const rig = await openAcpAdapterRig({ deps: { cancelTimeoutMs: 30 } }) + const rig = await openAcpAdapterRig() await rig.acquire() const { agent } = rig.child() agent.notify('session/update', replyChunk(GROK_TURN, 'Build finished; now')) @@ -194,7 +220,7 @@ describe('a send while a turn Grok began itself runs', () => { await sendHello(rig, 'b') await rig.settle() expect(rig.sent('session/cancel')).toHaveLength(1) - // `a` runs a whole turn, well past the Stop bound: a steer's cancel never ends Grok. + // `a` runs a whole turn: a steer's cancel never ends Grok. await new Promise((resolve) => setTimeout(resolve, 120)) await rig.settle() expect(rig.lifecycle).toEqual([]) diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index af2a879ed19f..c972e30346b7 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -1,8 +1,9 @@ // The sends of one ACP session. ACP runs one prompt at a time, so a message sent while Orca's prompt // runs steers: that prompt is cancelled (the session stays) and the message goes as the next prompt -// once the agent answers the cancel, however long that takes. A steer waits here until then; one -// behind it cancels it in turn, so the last one runs, and a Stop withdraws what waits. A turn the agent began itself is not Orca's -// to cut short: a message sent during it goes to the agent at once. Each send is settled exactly once: +// once the agent answers the cancel, however long that takes. Each running prompt gets one cancel, +// however many steers arrive; steers wait here, the last one runs, and a Stop withdraws what waits. +// A turn the agent began itself is not Orca's to cut short: a message sent during it goes to the +// agent at once. Each send is settled exactly once: // accepted when the agent's first event for its turn (or its answer) arrives, rejected when the // agent refused it or it never left Orca, unknown when the agent died with it or its connection // broke before it answered. @@ -21,7 +22,7 @@ import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import { AcpAgentError } from './acp-errors' -import type { AcpSessionRuntime } from './acp-session-runtime' +import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpStructuredLane } from './acp-structured-lane' import type { ContentBlock } from './generated/acp-protocol.generated' @@ -47,7 +48,9 @@ export function acpPromptBlocks(body: AgentJournalMessageItem): ContentBlock[] | type Send = { clientMessageId: string; prompt: ContentBlock[]; requestedAt: number } export type AcpStructuredTurnsDeps = { - runtime: Pick + connection: Pick + /** Answers the agent's open requests cancelled, as a steer's cancel ends what they belong to. */ + withdrawRequests: () => void lane: AcpStructuredLane agentName: string now: () => number @@ -56,6 +59,8 @@ export type AcpStructuredTurnsDeps = { export class AcpStructuredTurns { private active: Send | null = null + /** The running send a steer already cancelled: one cancel per prompt, retried if its write failed. */ + private steerCancelled: Send | null = null /** Steers waiting for the prompt ahead of them to answer its cancel. */ private readonly steers: Send[] = [] private readonly unsettled = new Set() @@ -116,6 +121,7 @@ export class AcpStructuredTurns { this.deps.settle({ clientMessageId: active.clientMessageId, state: 'unknown', reason }) } this.active = null + this.steerCancelled = null this.notifyIdle() } @@ -126,7 +132,7 @@ export class AcpStructuredTurns { lane.apply(opened.events) // The agent echoes this id on every event of the turn, so its rows join the turn Orca opened. const meta = { promptId: opened.promptId, requestId: opened.promptId } - const answered = this.deps.runtime.prompt(send.prompt, meta) + const answered = this.deps.connection.prompt(send.prompt, meta) if (this.steers.length > 0) { this.cancelForSteer() } @@ -168,6 +174,9 @@ export class AcpStructuredTurns { if (this.active === send) { this.active = null } + if (this.steerCancelled === send) { + this.steerCancelled = null + } const next = this.ended ? undefined : this.steers.shift() if (next) { this.start(next) @@ -176,10 +185,24 @@ export class AcpStructuredTurns { } } - /** Answers the agent's open requests cancelled and asks once to end the running prompt; never - * bounded, so a slow answer only delays the steer, and a Stop still ends the session. */ + /** Asks once per running prompt to end it; never bounded, so a slow answer only delays the steer, + * and a Stop still ends the session. */ private cancelForSteer(): void { - void this.deps.runtime.requestSteerCancel().catch(() => undefined) + const send = this.active + if (!send) { + return + } + this.deps.withdrawRequests() + if (this.steerCancelled === send) { + return + } + this.steerCancelled = send + this.deps.connection.cancel().catch(() => { + // Never written: the next steer may ask again. + if (this.steerCancelled === send) { + this.steerCancelled = null + } + }) } private notifyIdle(): void { diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 0ccf5c57c7e2..23ee50706dd4 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -31,7 +31,7 @@ import { } from './structured-agent-account-home' import { ACP_LAUNCH_SPECS, type AcpLaunchSpec } from '../acp/acp-launch-specs' import { acpStructuredAgentDefinition } from '../acp/acp-structured-agent-definitions' -import { spawnAcpStructuredChild } from '../acp/acp-structured-child' +import { createAcpAgentConnection } from '../acp/acp-agent-connection' import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch-resolution' import { AcpStructuredSessionAdapter } from '../acp/acp-structured-session-adapter' @@ -167,7 +167,7 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio ...(deps.resolveAgentLaunchEnv ? { resolveLaunchEnv: deps.resolveAgentLaunchEnv } : {}), ...(deps.resolveAgentFullAccess ? { resolveFullAccess: deps.resolveAgentFullAccess } : {}) }), - spawnChild: deps.spawnAcpChild ?? ((launch) => spawnAcpStructuredChild(launch)), + connect: (launch, options) => createAcpAgentConnection(launch, options), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), onDispatchSettledLate: followUps.onDispatchSettledLate, logger: deps.logger, diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 697dc0093e9e..8389d09d1594 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -59,7 +59,6 @@ import { modelCatalogHostDeps, type RuntimeAgentAccountHomeResolver } from './structured-agent-model-catalog-wiring' -import type { SpawnAcpStructuredChild } from '../acp/acp-structured-child' /** Whether this profile holds a structured chat: a record or tab in the journal database, or the * records file a profile from before it carries while the database still owes its copy. */ @@ -113,8 +112,6 @@ export type StructuredAgentSessionRuntimeDeps = { resolveAgentFullAccess?: (agent: string) => boolean /** The user's per-agent environment overlay, for agents with no lane-specific resolver. */ resolveAgentLaunchEnv?: (agent: string) => Record - /** ACP agent processes are overridden only to drive the runtime against scripted agents. */ - spawnAcpChild?: SpawnAcpStructuredChild /** Raw settings getter; the reader that fails closed around it is built here, in checked code. */ getClaudeManagedAccountGateSettings?: () => ClaudeManagedAccountGateSettings resolveEnvironment?: () => Promise From fa957dc0a48142b498580ab635a500e6badf8394 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:11:11 -0700 Subject: [PATCH 249/269] fix(acp): Grok signs in on its own machine with its API key or cached sign-in When Grok reports that it needs authentication, Orca now names a sign-in method on the machine Grok runs on, read from the same environment Grok was launched with: xai.api_key when XAI_API_KEY is set there and Grok offers that method, else cached_token when Grok offers it, else none and the chat keeps the existing not-signed-in refusal. The rule lives in Grok's launch spec; the adapter applies any agent's rule for new and reopened sessions through the protocol client's caller-named method (authenticate, then retry once). No new sign-in UI; interactive methods are never chosen. --- src/main/acp/acp-launch-specs.ts | 13 +++ src/main/acp/acp-structured-acquire.ts | 13 ++- src/main/acp/acp-structured-auth.test.ts | 101 +++++++++++++++++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 src/main/acp/acp-structured-auth.test.ts diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 88ff46659503..2b705aa0dd7f 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -21,6 +21,12 @@ export type AcpLaunchSpec = { dialect: AcpDialect /** The agent's own sign-in command, for a person to run when it reports auth required. */ loginCommand: readonly string[] + /** Which advertised sign-in method to use when the agent reports auth required, read from the + * environment it was launched with (on its own machine); none leaves it not signed in. */ + authMethod?(input: { + advertised: readonly string[] + env: Readonly> + }): string | undefined /** Variable naming the agent's config directory, pinned as each record's account home. */ accountHomeVariable: string /** The config directory the agent uses when the variable is unset, under the user's home. */ @@ -37,6 +43,13 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { env: {}, dialect: GROK_ACP_DIALECT, loginCommand: ['grok', 'login'], + // An API key in Grok's own environment, else the sign-in Grok already cached; never interactive. + authMethod: ({ advertised, env }) => + env.XAI_API_KEY?.trim() && advertised.includes('xai.api_key') + ? 'xai.api_key' + : advertised.includes('cached_token') + ? 'cached_token' + : undefined, accountHomeVariable: 'GROK_HOME', defaultAccountHome: (homePath) => join(homePath, '.grok'), installDirectories: (accountHomePath) => [join(accountHomePath, 'bin')] diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 5685b681bb38..83bb4700b835 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -207,7 +207,13 @@ export async function acquireAcpStructuredSession(input: { await identity.onSpawned(pid) const agentName = acpAgentName(spec.agent) try { - await connection.initialize() + const initialized = await connection.initialize() + // Chosen here, on the machine Grok runs on, from the environment it was launched with. + const authMethodId = spec.authMethod?.({ + advertised: (initialized.authMethods ?? []).map((method) => method.id), + env: launch.env + }) + const auth = authMethodId === undefined ? {} : { authMethodId } const resume = launch.resume let started: Awaited> | null = null let liveLane: AcpStructuredLane | null = null @@ -219,7 +225,8 @@ export async function acquireAcpStructuredSession(input: { started = await connection.start({ cwd: launch.cwd, mcpServers: [], - sessionId: resume.sessionId + sessionId: resume.sessionId, + ...auth }) slot.reattaching = false attaching.translator.finishLoad() @@ -236,7 +243,7 @@ export async function acquireAcpStructuredSession(input: { if (!started || !liveLane) { liveLane?.dispose() slot.lane = null - started = await connection.start({ cwd: launch.cwd, mcpServers: [] }) + started = await connection.start({ cwd: launch.cwd, mcpServers: [], ...auth }) liveLane = makeLane(started.sessionId) } options.adoptSession(started.response) diff --git a/src/main/acp/acp-structured-auth.test.ts b/src/main/acp/acp-structured-auth.test.ts new file mode 100644 index 000000000000..55d744051a42 --- /dev/null +++ b/src/main/acp/acp-structured-auth.test.ts @@ -0,0 +1,101 @@ +// Grok signs in on the machine it runs on: on auth required, with the API key in its own launch +// environment, else the sign-in it already cached; with neither, the chat reports it signed out. + +import { afterEach, describe, expect, it } from 'vitest' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { + GROK_CONFIG_OPTIONS, + openAcpAdapterRig, + PROVIDER_SESSION +} from './acp-structured-adapter.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const BOTH = [ + { id: 'xai.api_key', name: 'API key' }, + { id: 'cached_token', name: 'Cached sign-in' } +] + +/** Grok refuses to open a session until it is authenticated. */ +function requiresSignIn(agent: AcpScriptedAgent): void { + let signedIn = false + agent.on('authenticate', (frame) => { + signedIn = true + agent.reply(frame, {}) + }) + const opens = (method: string, result: unknown) => + agent.on(method, (frame) => + signedIn ? agent.reply(frame, result) : agent.fail(frame, -32000, 'Authentication required') + ) + opens('session/new', { sessionId: PROVIDER_SESSION, configOptions: GROK_CONFIG_OPTIONS }) + opens('session/load', { configOptions: GROK_CONFIG_OPTIONS }) +} + +async function signInWith(options: { + env: Record + authMethods: { id: string; name: string }[] + resume?: boolean +}) { + const rig = await openAcpAdapterRig({ + launch: { + env: options.env, + ...(options.resume + ? { resume: { sessionId: PROVIDER_SESSION, key: 'acp-key', mayBeUnsaved: false } } + : {}) + }, + initialize: { authMethods: options.authMethods }, + script: requiresSignIn + }) + const acquired = await rig.acquire().catch((error: unknown) => error) + const methods = rig + .sent('authenticate') + .map((frame) => (frame.params as { methodId: string }).methodId) + return { rig, acquired, methods } +} + +describe('Grok sign-in on its own machine', () => { + it("uses the API key in Grok's launch environment when Grok offers that method", async () => { + const { acquired, methods } = await signInWith({ + env: { XAI_API_KEY: 'xai-1' }, + authMethods: BOTH + }) + expect(methods).toEqual(['xai.api_key']) + expect(acquired).toMatchObject({ link: { origin: 'created' } }) + }) + + it('uses the cached sign-in without a key, or when Grok does not offer the key method', async () => { + const noKey = await signInWith({ env: {}, authMethods: BOTH }) + expect(noKey.methods).toEqual(['cached_token']) + expect(noKey.acquired).toMatchObject({ link: { origin: 'created' } }) + const keyNotOffered = await signInWith({ + env: { XAI_API_KEY: 'xai-1' }, + authMethods: [{ id: 'cached_token', name: 'Cached sign-in' }] + }) + expect(keyNotOffered.methods).toEqual(['cached_token']) + }) + + it('reports Grok signed out when it offers neither, sending no sign-in', async () => { + const { acquired, methods } = await signInWith({ + env: { XAI_API_KEY: 'xai-1' }, + authMethods: [{ id: 'browser', name: 'Browser' }] + }) + expect(methods).toEqual([]) + expect(acquired).toBeInstanceOf(AgentSessionAcquisitionRefusal) + expect(acquired).toMatchObject({ reason: 'notSignedIn' }) + }) + + it('signs in the same way when it reopens a saved session', async () => { + const { rig, acquired, methods } = await signInWith({ + env: { XAI_API_KEY: 'xai-1' }, + authMethods: BOTH, + resume: true + }) + expect(methods).toEqual(['xai.api_key']) + expect(acquired).toMatchObject({ link: { origin: 'resumed' } }) + expect(rig.sent('session/new')).toEqual([]) + }) +}) From bb32fe899dc98d3c69f1bc5e43379e9435b50148 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:32:57 -0700 Subject: [PATCH 250/269] fix(acp): the adapter decides which of Grok's requests reach the person The turn owner now admits every agent request, permission or question, from its own turn state: a request reaches the person only while Orca's prompt runs and no steer or Stop is cutting it short (a question may also come from a turn Grok began itself, until a Stop). Anything else gets the agent's own cancelled reply and opens no card, so a question arriving after Stop or during a steer never appears. A steer, like a Stop, withdraws the requests already open; an answer already being saved is still sent. The protocol client's abort-on-cancel path is no longer used: after the connection change its request signal aborts only when the connection closes. --- src/main/acp/acp-structured-acquire.ts | 15 ++- src/main/acp/acp-structured-prompts.ts | 11 ++- .../acp-structured-request-admission.test.ts | 97 +++++++++++++++++++ .../acp/acp-structured-session-adapter.ts | 9 +- src/main/acp/acp-structured-turns.ts | 29 +++++- 5 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 src/main/acp/acp-structured-request-admission.test.ts diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 83bb4700b835..0cdeeea3a782 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -96,7 +96,15 @@ export async function acquireAcpStructuredSession(input: { reattaching: false } const options = new AcpStructuredOptions() - const prompts = new AcpStructuredPrompts(() => slot.lane) + // Orca's own prompt may ask; so may a turn the agent began itself, for a question it cannot answer + // alone. Nothing may while a Stop or steer cuts its turn short, or when no turn is open. + const prompts = new AcpStructuredPrompts( + () => slot.lane, + () => + session !== null && + (session.turns.acceptsRequests || + (!session.turns.running && !session.turns.stopped && session.lane.openTurnId !== null)) + ) const early: (() => void)[] = [] const whenLane = (deliver: () => void): void => { if (slot.lane) { @@ -119,8 +127,9 @@ export async function acquireAcpStructuredSession(input: { { clientInfo: { name: 'orca', version: '1' }, onPermission: (request, context) => { - if (!session?.turns.running) { - // No prompt of Orca's runs (a turn the agent began itself): nobody is there to ask. + if (!session?.turns.acceptsRequests) { + // No prompt of Orca's runs (or a turn the agent began itself), or a Stop or steer is + // cutting it short: nobody is there to ask. return { outcome: { outcome: 'cancelled' } } } if (launch.fullAccess) { diff --git a/src/main/acp/acp-structured-prompts.ts b/src/main/acp/acp-structured-prompts.ts index f934ba7f9a27..dea02f31bab3 100644 --- a/src/main/acp/acp-structured-prompts.ts +++ b/src/main/acp/acp-structured-prompts.ts @@ -32,7 +32,11 @@ type OpenRequest = { export class AcpStructuredPrompts { private readonly open = new Map() - constructor(private readonly lane: () => AcpStructuredLane | null) {} + constructor( + private readonly lane: () => AcpStructuredLane | null, + /** Whether a request arriving now belongs to a turn that may still ask the person. */ + private readonly admits: () => boolean + ) {} get size(): number { return this.open.size @@ -45,6 +49,11 @@ export class AcpStructuredPrompts { throw new AcpRpcError(-32603, 'ACP request arrived with no session to show it') } const translated = lane.translator.request(method, params, context.id) + if (translated.presentation && !this.admits()) { + // Its turn is being stopped or steered, or none is open: the agent hears its own cancelled + // reply and no card opens. + return Promise.resolve(translated.presentation.reply(null)) + } lane.apply(translated.events) const opened = translated.events.find((event) => event.type === 'request.open') if (!translated.presentation || opened?.type !== 'request.open') { diff --git a/src/main/acp/acp-structured-request-admission.test.ts b/src/main/acp/acp-structured-request-admission.test.ts new file mode 100644 index 000000000000..65ca5f29d90f --- /dev/null +++ b/src/main/acp/acp-structured-request-admission.test.ts @@ -0,0 +1,97 @@ +// What Grok asks reaches the person only while the turn it belongs to may still ask: a Stop or a +// steer withdraws what is open and refuses what arrives later with Grok's own cancelled reply, with +// no card; an answer the person already gave is still sent. + +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk, + sendHello +} from './acp-structured-adapter.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const question = (toolCallId: string) => ({ + sessionId: PROVIDER_SESSION, + toolCallId, + questions: [{ question: 'Which file?', options: [{ label: 'a.ts' }, { label: 'b.ts' }] }] +}) + +const permission = (toolCallId: string) => ({ + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId, title: 'Write file' }, + options: [{ optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }] +}) + +async function runningPrompt() { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) + await rig.settle() + const cards = async () => + (await rig.rig.rows()).filter( + (row) => row.body.kind === 'question' || row.body.kind === 'approval' + ) + return { rig, agent: rig.child().agent, cards } +} + +describe('Grok requests and the turn they belong to', () => { + it('shows a question while its turn runs, and withdraws it on Stop with Grok own reply', async () => { + const { rig, agent, cards } = await runningPrompt() + const asked = agent.request(1, 'x.ai/ask_user_question', question('call-1')) + await rig.settle() + expect(await cards()).toHaveLength(1) + await rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 }) + expect(await asked).toMatchObject({ result: { outcome: 'cancelled' } }) + }) + + it('withdraws an open question when a steer cancels its turn', async () => { + const { rig, agent } = await runningPrompt() + const asked = agent.request(6, 'x.ai/ask_user_question', question('call-6')) + await rig.settle() + await sendHello(rig, 'steer') + expect(await asked).toMatchObject({ result: { outcome: 'cancelled' } }) + expect(rig.sent('session/cancel')).toHaveLength(1) + }) + + it('refuses a question that arrives after a Stop, opening no card', async () => { + const { rig, agent, cards } = await runningPrompt() + await rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 }) + const late = await agent.request(2, 'x.ai/ask_user_question', question('call-2')) + expect(late).toMatchObject({ result: { outcome: 'cancelled' } }) + expect(await cards()).toEqual([]) + }) + + it('refuses a permission or a question that arrives while a steer cuts the turn short', async () => { + const { rig, agent, cards } = await runningPrompt() + await sendHello(rig, 'steer') + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(1) + expect( + await agent.request(3, 'session/request_permission', permission('call-3')) + ).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect(await agent.request(4, 'x.ai/ask_user_question', question('call-4'))).toMatchObject({ + result: { outcome: 'cancelled' } + }) + expect(await cards()).toEqual([]) + }) + + it('refuses a question when no turn is open', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + expect(await agent.request(5, 'x.ai/ask_user_question', question('call-5'))).toMatchObject({ + result: { outcome: 'cancelled' } + }) + expect((await rig.rig.rows()).filter((row) => row.body.kind === 'question')).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 88f5489166bb..722f694554d7 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -164,11 +164,12 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte if (input.turnId !== undefined && liveTurnId !== null && input.turnId !== liveTurnId) { return { cancelled: false, refusal: { turnNotRunning: true } } } - const withdrew = session.turns.withdrawSteers() + if (!session.turns.running && session.lane.openTurnId === null && !session.turns.holdsSteers) { + return { cancelled: false, refusal: { turnNotRunning: true } } + } + session.turns.stop() if (!session.turns.running && session.lane.openTurnId === null) { - return withdrew - ? { cancelled: true } - : { cancelled: false, refusal: { turnNotRunning: true } } + return { cancelled: true } } // The agent hears its own cancelled reply to what it asked, then may end its turn its own way; // the host ends the process once that lands or the grace runs out (`awaitStoppedRequestEnd`), diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index c972e30346b7..4277b3c0dded 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -66,6 +66,8 @@ export class AcpStructuredTurns { private readonly unsettled = new Set() private readonly idleWaiters = new Set<() => void>() private ended = false + /** A Stop reached this session; it ends with the process. */ + private stopping = false constructor(private readonly deps: AcpStructuredTurnsDeps) {} @@ -73,6 +75,30 @@ export class AcpStructuredTurns { return this.active !== null } + /** Whether an agent request may reach the person: Orca's prompt runs and nobody is cutting it + * short (no steer's cancel, no Stop). */ + get acceptsRequests(): boolean { + return ( + !this.stopping && this.active !== null && this.steerCancelled !== this.active && !this.ended + ) + } + + /** Steers wait behind the running prompt's cancel. */ + get holdsSteers(): boolean { + return this.steers.length > 0 + } + + /** Whether a Stop reached this session. */ + get stopped(): boolean { + return this.stopping + } + + /** A Stop: held steers never reach the agent, and nothing it asks from now on is shown. */ + stop(): boolean { + this.stopping = true + return this.withdrawSteers() + } + /** Resolves once no prompt of Orca's is running. */ whenIdle(): Promise { if (!this.active) { @@ -101,8 +127,7 @@ export class AcpStructuredTurns { } } - /** A Stop: held steers never reach the agent. */ - withdrawSteers(): boolean { + private withdrawSteers(): boolean { const withdrawn = this.steers.splice(0) for (const send of withdrawn) { this.reject(send.clientMessageId, agentSessionFailureFact('cancelled')) From 9c7c2ebbe97ca3676898c411373ebb71f7c9330a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:58:08 -0700 Subject: [PATCH 251/269] fix(acp): a plan Grok proposes shows as a plan, with no approval card When Grok leaves plan mode it asks the client to approve its plan (x.ai/exit_plan_mode). Orca showed a blocking 'Approve plan / Request changes' card for it; the common pattern has no such gate. Now the plan goes into the chat's existing Plan row (the plan-document status row Codex and ACP plan updates already use) and the request is answered at once with 'abandoned' plus feedback telling Grok to stop and wait for the person's feedback or a request to implement it in a later turn, so nothing is approved on the person's behalf. Dialects gain settleRequest for requests answered without asking anyone. --- src/main/acp/acp-dialects/acp-dialect.ts | 6 +++ src/main/acp/acp-dialects/grok-dialect.ts | 3 +- src/main/acp/acp-dialects/grok-requests.ts | 52 +++++++------------ src/main/acp/acp-structured-prompts.ts | 7 +++ .../acp-structured-session-adapter.test.ts | 34 ++++++++++-- src/main/acp/acp-timeline-dialect.test.ts | 18 +++---- src/main/acp/acp-timeline-recordings.test.ts | 16 +++--- src/main/acp/acp-timeline-recovery.test.ts | 29 ++++++----- src/main/acp/acp-timeline-requests.ts | 44 ++++++++++++++-- src/main/acp/acp-timeline-translator.ts | 8 +-- 10 files changed, 138 insertions(+), 79 deletions(-) diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 3b38b9cc1bdd..596ddcb4049a 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -11,6 +11,10 @@ export type AcpRequestPresentation = { reply(response: AgentSessionPromptResponse | null): unknown } +/** A request the client answers at once, asking no one: the reply, and a plan it carried for the + * chat's existing plan row. */ +export type AcpRequestSettlement = { reply: unknown; plan?: string } + export type AcpBackgroundTaskUpdate = Pick & Partial> & { /** Used only until a provider description is known. */ @@ -45,6 +49,8 @@ export type AcpDialect = { notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined contextWindow?(models: unknown): number | undefined request?(method: string, params: unknown): AcpRequestPresentation | undefined + /** Requests answered at once instead of shown to the person. */ + settleRequest?(method: string, params: unknown): AcpRequestSettlement | undefined /** The provider's words in a `session/prompt` error answer, when its message is generic. */ promptErrorDetail?(error: AcpAgentError): string | undefined /** The row for a failed turn the provider gave no words for. */ diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index 09213c973894..609a9c313c5e 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import { acpNotificationEnvelopeSchema } from '../acp-context-usage' import type { AcpDialect, AcpDialectNotification } from './acp-dialect' -import { grokRequest } from './grok-requests' +import { grokRequest, grokSettleRequest } from './grok-requests' import { grokBackgroundTaskNotification, grokToolBackgroundTasks } from './grok-background-tasks' const tokenCount = z.number().int().nonnegative() @@ -173,6 +173,7 @@ export const GROK_ACP_DIALECT: AcpDialect = { return parsed.success ? parsed.data['x.ai/tool'].name : undefined }, request: grokRequest, + settleRequest: grokSettleRequest, toolBackgroundTasks: grokToolBackgroundTasks, notification, contextWindow, diff --git a/src/main/acp/acp-dialects/grok-requests.ts b/src/main/acp/acp-dialects/grok-requests.ts index 26dbee0fae25..b86788120616 100644 --- a/src/main/acp/acp-dialects/grok-requests.ts +++ b/src/main/acp/acp-dialects/grok-requests.ts @@ -5,7 +5,7 @@ import { } from '../../../shared/agent-session-question-answer' import { AcpRpcError } from '../acp-errors' import { pendingAcpResolution } from '../acp-timeline-requests' -import type { AcpRequestPresentation } from './acp-dialect' +import type { AcpRequestPresentation, AcpRequestSettlement } from './acp-dialect' const questionRequestSchema = z.object({ sessionId: z.string(), @@ -98,47 +98,35 @@ function questionRequest(params: unknown): AcpRequestPresentation { } } -function planRequest(params: unknown): AcpRequestPresentation { +/** What Grok hears once Orca has shown its plan: no approval is given for the person. */ +const PLAN_SHOWN_FEEDBACK = + 'Orca showed your plan to the user. Stop here and wait for their feedback or a request to implement it in a later turn.' + +/** Grok's plan-mode exit: its plan goes to the chat's plan row and the gate is answered at once + * (`abandoned`), so the turn ends instead of waiting on an approval card. */ +function settlePlanRequest(params: unknown): AcpRequestSettlement { const parsed = planRequestSchema.safeParse(params) if (!parsed.success) { - throw new AcpRpcError(-32602, 'Invalid agent plan approval request') + throw new AcpRpcError(-32602, 'Invalid agent plan request') } return { - body: { - kind: 'approval', - title: 'Approve plan', - detail: null, - subject: { - kind: 'plan', - text: parsed.data.planContent ?? 'The agent exited plan mode without writing a plan.' - }, - options: [ - { id: 'approved', label: 'Approve plan' }, - { id: 'request_changes', label: 'Request changes' } - ], - resolution: pendingAcpResolution - }, - reply: (response) => { - if (response === null) { - return { outcome: 'abandoned' } - } - if ( - response.kind !== 'option' || - !['approved', 'request_changes'].includes(response.optionId) - ) { - throw new AcpRpcError(-32602, 'Plan answer must select an offered option') - } - return { outcome: response.optionId } - } + reply: { outcome: 'abandoned', feedback: PLAN_SHOWN_FEEDBACK }, + plan: parsed.data.planContent?.trim() || 'The agent exited plan mode without writing a plan.' } } +export function grokSettleRequest( + method: string, + params: unknown +): AcpRequestSettlement | undefined { + return ['_x.ai/exit_plan_mode', 'x.ai/exit_plan_mode'].includes(method) + ? settlePlanRequest(params) + : undefined +} + export function grokRequest(method: string, params: unknown): AcpRequestPresentation | undefined { if (['_x.ai/ask_user_question', 'x.ai/ask_user_question'].includes(method)) { return questionRequest(params) } - if (['_x.ai/exit_plan_mode', 'x.ai/exit_plan_mode'].includes(method)) { - return planRequest(params) - } return undefined } diff --git a/src/main/acp/acp-structured-prompts.ts b/src/main/acp/acp-structured-prompts.ts index dea02f31bab3..dbb547b236c3 100644 --- a/src/main/acp/acp-structured-prompts.ts +++ b/src/main/acp/acp-structured-prompts.ts @@ -49,6 +49,13 @@ export class AcpStructuredPrompts { throw new AcpRpcError(-32603, 'ACP request arrived with no session to show it') } const translated = lane.translator.request(method, params, context.id) + if (translated.settled) { + // Answered at once; what it carried shows only while its turn may still show anything. + if (this.admits()) { + lane.apply(translated.events) + } + return Promise.resolve(translated.settled.reply) + } if (translated.presentation && !this.admits()) { // Its turn is being stopped or steered, or none is open: the agent hears its own cancelled // reply and no card opens. diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 7130bca98dd9..efe7a6f43a03 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -261,28 +261,52 @@ describe('ACP structured session adapter: approvals', () => { }) describe('ACP structured session adapter: requests a Stop cancels', () => { - it("answers an open plan approval with Grok's own cancelled reply once a Stop cancels it", async () => { + it("answers an open question with Grok's own cancelled reply once a Stop cancels it", async () => { const rig = await openAcpAdapterRig() await rig.acquire() await send(rig, 'send-1') const prompt = await rig.frame('session/prompt') const { agent } = rig.child() - const plan = agent.request(5, '_x.ai/exit_plan_mode', { + const question = agent.request(5, '_x.ai/ask_user_question', { sessionId: PROVIDER_SESSION, toolCallId: 'call-2', - planContent: '# Plan' + questions: [{ question: 'Which?', options: [{ label: 'A' }] }] }) await waitFor(async () => { - expect((await rig.rig.rows()).some((row) => row.body.kind === 'approval')).toBe(true) + expect((await rig.rig.rows()).some((row) => row.body.kind === 'question')).toBe(true) }) agent.on('session/cancel', () => agent.reply(prompt, { stopReason: 'cancelled' })) await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ cancelled: true }) - expect(await plan).toMatchObject({ result: { outcome: 'abandoned' } }) + expect(await question).toMatchObject({ result: { outcome: 'cancelled' } }) expect(agent.frames.filter((frame) => frame.id === 5)).toHaveLength(1) }) + it("shows Grok's plan in the plan row and answers its plan-mode exit at once, with no card", async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + await rig.frame('session/prompt') + const { agent } = rig.child() + const plan = await agent.request(7, '_x.ai/exit_plan_mode', { + sessionId: PROVIDER_SESSION, + toolCallId: 'call-3', + planContent: '# Plan\n- step' + }) + expect(plan).toMatchObject({ result: { outcome: 'abandoned' } }) + await rig.settle() + const rows = await rig.rig.rows() + expect(rows.filter((row) => row.body.kind === 'approval')).toEqual([]) + expect(rows.map((row) => row.body)).toContainEqual( + expect.objectContaining({ + kind: 'status', + presentation: 'plan-document', + text: '# Plan\n- step' + }) + ) + }) + it('sends a permission answer whose save was under way when the Stop landed', async () => { const rig = await openAcpAdapterRig() await rig.acquire() diff --git a/src/main/acp/acp-timeline-dialect.test.ts b/src/main/acp/acp-timeline-dialect.test.ts index 195ab2d0a91a..27ce8c83d8e5 100644 --- a/src/main/acp/acp-timeline-dialect.test.ts +++ b/src/main/acp/acp-timeline-dialect.test.ts @@ -82,7 +82,7 @@ describe('ACP dialect request and turn boundaries', () => { }) }) - it('encodes labels and free text under the question text, validates grouped choices, and gives exact plan replies', async () => { + it('encodes labels and free text under the question text, validates grouped choices, and settles a plan without a gate', async () => { const { translator } = await dialectRig() const ask = translator.request('_x.ai/ask_user_question', question, 0).presentation! expect( @@ -134,17 +134,17 @@ describe('ACP dialect request and turn boundaries', () => { outcome: 'accepted', answers: { 'Choose a test?': ['Unit', 'Integration'], 'Another?': 'Yes' } }) + // A plan is shown, never put to the person as an approval, and never approved for them. const plan = translator.request( '_x.ai/exit_plan_mode', - { sessionId: 'provider-1', toolCallId: 'plan-1', planContent: 'Plan' }, + { sessionId: 'provider-1', toolCallId: 'plan-1', planContent: '# Plan' }, 2 - ).presentation! - expect(plan.reply({ kind: 'option', optionId: 'approved' })).toEqual({ outcome: 'approved' }) - expect(plan.reply({ kind: 'option', optionId: 'request_changes' })).toEqual({ - outcome: 'request_changes' - }) - expect(plan.reply(null)).toEqual({ outcome: 'abandoned' }) - expect(() => plan.reply({ kind: 'option', optionId: 'allow' })).toThrow('offered option') + ) + expect(plan.presentation).toBeUndefined() + expect(plan.settled?.reply).toEqual({ outcome: 'abandoned', feedback: expect.any(String) }) + expect(plan.events).toMatchObject([ + { body: { kind: 'status', presentation: 'plan-document', text: '# Plan' } } + ]) }) it('keeps client prompt settlement on the response, and never gives an autonomous turn invented success', async () => { diff --git a/src/main/acp/acp-timeline-recordings.test.ts b/src/main/acp/acp-timeline-recordings.test.ts index e4402b37e864..017f53c3fec9 100644 --- a/src/main/acp/acp-timeline-recordings.test.ts +++ b/src/main/acp/acp-timeline-recordings.test.ts @@ -106,18 +106,20 @@ describe('recorded ACP traffic through the journal', () => { expect(await fixture.rig.turns()).toHaveLength(2) }) - it('answers questions and plan approval with the recorded exact reply shapes', async () => { + it('answers questions with the recorded reply shape and shows the plan as a plan, not a gate', async () => { const fixture = await openAcpFixtureRig() const rows = await fixture.feed(await readAcpFixture('s5-plan-approved')) expect(rows.flatMap((row) => (row.body.kind === 'question' ? [row.body] : []))).toMatchObject([ { resolution: { state: 'resolved', answers: [{ questionId: 'q1', optionIds: ['o1'] }] } } ]) - expect(rows.flatMap((row) => (row.body.kind === 'approval' ? [row.body] : []))).toMatchObject([ - { - subject: { kind: 'plan', text: '# Plan\n\nUpdate the example and run its tests.' }, - resolution: { state: 'resolved', selectedOptionId: 'approved' } - } - ]) + expect(rows.filter((row) => row.body.kind === 'approval')).toEqual([]) + expect(rows.map((row) => row.body)).toContainEqual( + expect.objectContaining({ + kind: 'status', + presentation: 'plan-document', + text: '# Plan\n\nUpdate the example and run its tests.' + }) + ) expect((await fixture.rig.turns())[0]!.outcome).toBe('success') }) diff --git a/src/main/acp/acp-timeline-recovery.test.ts b/src/main/acp/acp-timeline-recovery.test.ts index 6bc11b594756..84c1cbfcc2cc 100644 --- a/src/main/acp/acp-timeline-recovery.test.ts +++ b/src/main/acp/acp-timeline-recovery.test.ts @@ -33,24 +33,25 @@ describe('ACP reviewed traffic and recovery', () => { }) it.each([undefined, null])( - 'accepts an empty plan request and replies with the valid approval shape (%s)', + 'accepts an empty plan request: placeholder plan row, answered at once without approving (%s)', async (planContent) => { const f = await openAcpFixtureRig() for (const method of ['_x.ai/exit_plan_mode', 'x.ai/exit_plan_mode']) { - const presentation = f + const translated = f .lane() - .request( - method, - { sessionId: 'session-1', toolCallId: 'plan', planContent }, - 1 - ).presentation - expect(presentation?.body).toMatchObject({ - kind: 'approval', - subject: { kind: 'plan', text: 'The agent exited plan mode without writing a plan.' } - }) - expect(presentation?.reply({ kind: 'option', optionId: 'approved' })).toEqual({ - outcome: 'approved' - }) + .request(method, { sessionId: 'session-1', toolCallId: 'plan', planContent }, 1) + expect(translated.presentation).toBeUndefined() + expect(translated.events).toMatchObject([ + { + type: 'item.update', + body: { + kind: 'status', + presentation: 'plan-document', + text: 'The agent exited plan mode without writing a plan.' + } + } + ]) + expect(translated.settled?.reply).toMatchObject({ outcome: 'abandoned' }) } } ) diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts index aa7e396c1368..176967409c9e 100644 --- a/src/main/acp/acp-timeline-requests.ts +++ b/src/main/acp/acp-timeline-requests.ts @@ -1,4 +1,8 @@ import { z } from 'zod' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect, AcpRequestPresentation } from './acp-dialects/acp-dialect' import type { AcpToolTimeline } from './acp-tool-timeline' @@ -57,19 +61,49 @@ export function translateAcpRequest( dialect: AcpDialect tools: AcpToolTimeline } -): { events: ProviderTimelineEvent[]; presentation?: AcpRequestPresentation } { +): { + events: ProviderTimelineEvent[] + presentation?: AcpRequestPresentation + /** Answered at once: nobody is asked. */ + settled?: { reply: unknown } +} { const session = requestSessionSchema.safeParse(params) if (!session.success || session.data.sessionId !== options.sessionId) { throw new AcpRpcError(-32602, 'ACP request belongs to an unknown session') } - const presentation = - method === 'session/request_permission' - ? acpPermissionPresentation(params) - : options.dialect.request?.(method, params) const tool = requestToolSchema.safeParse(params) const callId = tool.success ? (tool.data.toolCall?.toolCallId ?? tool.data.toolCallId) : undefined const turn = callId ? options.tools.turn(callId) : undefined const join = { thread: options.sessionId, ...(turn === undefined ? {} : { turn }) } + const settlement = + method === 'session/request_permission' + ? undefined + : options.dialect.settleRequest?.(method, params) + if (settlement) { + const plan = settlement.plan + return { + settled: { reply: settlement.reply }, + events: + plan === undefined + ? [] + : [ + { + type: 'item.update', + item: `${method}:${JSON.stringify(id)}`, + body: { + kind: 'status', + presentation: 'plan-document', + text: boundInlineText(plan, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + }, + join + } + ] + } + } + const presentation = + method === 'session/request_permission' + ? acpPermissionPresentation(params) + : options.dialect.request?.(method, params) if (!presentation) { return { events: [{ type: 'provider.frame', frameKind: `request:${method}`, payload: params, join }] diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index ed66a42c45dd..4d230b1f3092 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -3,11 +3,7 @@ import { BoundedMap } from '../../shared/bounded-map' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { acpNotificationEnvelopeSchema, AcpContextTimeline } from './acp-context-usage' import { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' -import { - GENERIC_ACP_DIALECT, - type AcpDialect, - type AcpRequestPresentation -} from './acp-dialects/acp-dialect' +import { GENERIC_ACP_DIALECT, type AcpDialect } from './acp-dialects/acp-dialect' import type { AcpAgentError } from './acp-errors' import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' @@ -218,7 +214,7 @@ export class AcpTimelineTranslator { method: string, params: unknown, id: string | number - ): { events: ProviderTimelineEvent[]; presentation?: AcpRequestPresentation } { + ): ReturnType { return translateAcpRequest(method, params, id, { sessionId: this.options.sessionId, dialect: this.dialect, From 7b8bdaa019992aa563950e58dec5c1861292db11 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:03:29 -0700 Subject: [PATCH 252/269] fix(orchestration): worker-start opens a Grok worker in a terminal, as before With the structured chat setting on, worker-start decided 'structured' for Grok and then the structured worker factory (Claude and Codex only) refused it, so the start failed; main opened a terminal Grok worker. Worker-start now decides with no registered agents beyond Claude and Codex, so Grok gets a terminal worker as before. agent.launch and the app's own launches still open Grok as a structured chat. Temporary until structured workers take registered agents. --- src/main/agent-launch/agent-launch-mode.ts | 5 ++- .../orchestration-worker-start-mode.ts | 5 ++- ...tion-worker-start-registered-agent.test.ts | 44 ++++++++++++++++--- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 21898bcc8a9e..961c8e1b6fb9 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -132,6 +132,9 @@ export function decideAgentLaunchMode(args: { placement: AgentLaunchModePlacement settings: AgentLaunchModeSettings | null | undefined vocabulary?: AgentLaunchModeVocabulary + /** Registered agents (beyond Claude and Codex) this surface can open as structured; defaults to + * every agent this host registers. */ + registeredStructuredAgents?: readonly string[] }): AgentLaunchModeReceipt { const { placement, settings } = args const vocabulary = args.vocabulary ?? DEFAULT_LAUNCH_VOCABULARY @@ -156,7 +159,7 @@ export function decideAgentLaunchMode(args: { reusesTerminal: Boolean(placement.terminal), hostCapabilities: RUNTIME_CAPABILITIES, // This host is the one that will run the agent, so its own registrations answer. - hostStructuredAgents: REGISTERED_STRUCTURED_AGENTS, + hostStructuredAgents: args.registeredStructuredAgents ?? REGISTERED_STRUCTURED_AGENTS, // The floating workspace has nowhere to keep a session, so it is decided here rather than left // to the host probe below, which cannot answer for a workspace with no record. WSL still is: // the create-support probe reads the resolved workspace rather than guessing from a diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts index 9cd9f51e0ac6..fcdaade4702b 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts @@ -55,7 +55,10 @@ export function decideWorkerStartMode(args: { return decideAgentLaunchMode({ placement: args.params, settings: args.settings, - vocabulary: WORKER_START_VOCABULARY + vocabulary: WORKER_START_VOCABULARY, + // The structured worker factory creates Claude and Codex only; any other agent (Grok included) + // starts as a terminal worker until structured workers take registered agents. + registeredStructuredAgents: [] }) } diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts index 97e870b26c41..175f650c7a1f 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts @@ -1,8 +1,14 @@ -// An orchestration worker for a registered agent (Grok) follows the same structured-chat setting as -// Claude and Codex: a chat when the user's default is a structured chat, a terminal when it is off. +// An orchestration worker for a registered agent (Grok) opens as a terminal worker, as before Grok +// had a structured chat: the structured worker factory creates Claude and Codex only, so a +// structured verdict for Grok would fail the start. Other launches still open Grok as a chat. import { describe, expect, it } from 'vitest' -import { decideWorkerStartMode } from './orchestration-worker-start-mode' +import { decideAgentLaunchMode } from '../../../agent-launch/agent-launch-mode' +import { + decideWorkerStartMode, + resolveWorkerStartModeOnHost +} from './orchestration-worker-start-mode' +import { createStructuredWorkerSessionForWorktree } from './orchestration/worker/worker-topology' const STRUCTURED_PREFERENCE = { experimentalNativeChat: true, @@ -11,12 +17,40 @@ const STRUCTURED_PREFERENCE = { } as const describe('a Grok worker and the structured-chat setting', () => { - it('starts as a structured chat while the setting is on', () => { + it('starts as a terminal worker while the setting is on; Claude starts structured', async () => { + const grok = decideWorkerStartMode({ + params: { agent: 'grok' }, + settings: STRUCTURED_PREFERENCE + }) + expect(grok).toMatchObject({ mode: 'terminal', reason: 'agent_without_structured_session' }) + const host = { getStructuredAgentSessionCreateSupport: async () => ({ supported: true }) } + expect(await resolveWorkerStartModeOnHost(host, grok, 'wt-1', 'grok')).toMatchObject({ + mode: 'terminal' + }) expect( - decideWorkerStartMode({ params: { agent: 'grok' }, settings: STRUCTURED_PREFERENCE }) + decideWorkerStartMode({ params: { agent: 'claude' }, settings: STRUCTURED_PREFERENCE }) ).toMatchObject({ mode: 'structured', reason: 'user_default' }) }) + it('still opens Grok as a structured chat for other launches', () => { + expect( + decideAgentLaunchMode({ placement: { agent: 'grok' }, settings: STRUCTURED_PREFERENCE }) + ).toMatchObject({ mode: 'structured' }) + }) + + it('would fail a structured Grok worker: the factory creates Claude and Codex only', async () => { + await expect( + createStructuredWorkerSessionForWorktree({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the factory refuses the agent before it reads the runtime. + runtime: {} as never, + worktreeId: 'wt-1', + agent: 'grok', + dispatchId: 'd-1', + effects: [] + }) + ).rejects.toThrow(/Structured workers support claude and codex/) + }) + it('starts as a terminal agent while the setting is off, as Claude does', () => { for (const agent of ['grok', 'claude']) { expect( From 606d1f6f9176dc164a7f17927e1f087a1119989c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:12:00 -0700 Subject: [PATCH 253/269] fix(acp): a prompt answer Orca can't read ends the turn instead of hanging it A session/prompt rejection that was not the agent's own error answer (an answer that fails Orca's schema, or one too large to read) left the turn running: the next message became a steer with nothing to cancel and was never sent or settled, and Stop waited its full grace. As in the common pattern, any prompt failure now ends the turn as failed (a failed-turn row without words, since none are the agent's) and settles the send, so the next message goes. Only a closed connection keeps the send running, for the connection-loss path to settle. --- ...ructured-session-adapter-lifecycle.test.ts | 22 ++++++++++++++----- src/main/acp/acp-structured-turns.ts | 17 ++++++++++---- src/main/acp/acp-timeline-translator.ts | 12 +++++----- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index 0ccd54ad88f1..bb845eb429fb 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -249,19 +249,31 @@ describe('ACP connection loss', () => { expect(rig.child().closes).toBe(1) }) - it('leaves the turn running on an answer Orca cannot read, until a Stop ends Grok', async () => { + it('ends the turn as failed on an answer Orca cannot read, and sends the next message', async () => { const rig = await openAcpAdapterRig() await rig.acquire() await sendHello(rig, 'garbled') const prompt = await rig.frame('session/prompt') rig.child().agent.notify('session/update', replyChunk('prompt:garbled', 'partial')) - rig.child().agent.reply(prompt, { stopReason: 42 }) + // Usage without the totals the schema requires: the answer fails to parse. + rig.child().agent.reply(prompt, { + stopReason: 'end_turn', + usage: { inputTokens: 10, outputTokens: 2 } + }) await rig.settle() - expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'running' }) + expect((await journalTurns(rig)).at(-1)).toMatchObject({ + state: 'completed', + outcome: 'failure' + }) + await sendHello(rig, 'next') + const next = await rig.frame('session/prompt', 1) + expect(rig.sent('session/cancel')).toEqual([]) + rig.child().agent.notify('session/update', replyChunk('prompt:next', 'ok')) + rig.child().agent.reply(next, { stopReason: 'end_turn' }) + await rig.settle() + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['garbled', 'next']) expect(rig.child().closes).toBe(0) expect(rig.lifecycle).toEqual([]) - await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) - expect(rig.child().exited).toBe(true) }) it('leaves Grok running when it ends its stdout but can still be written to', async () => { diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index 4277b3c0dded..df5d715d073a 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -21,7 +21,7 @@ import type { AgentJournalItemIdentity, AgentJournalMessageItem } from '../../shared/agent-session-journal-types' -import { AcpAgentError } from './acp-errors' +import { AcpAgentError, AcpConnectionClosedError } from './acp-errors' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpStructuredLane } from './acp-structured-lane' import type { ContentBlock } from './generated/acp-protocol.generated' @@ -171,12 +171,21 @@ export class AcpStructuredTurns { this.finish(send) }, (error: unknown) => { - if (this.active !== send) { + if (this.active !== send || error instanceof AcpConnectionClosedError) { + // A lost connection: the session's end settles the send, as unknown. return } if (!(error instanceof AcpAgentError)) { - // No answer from the agent, so neither a refusal nor an end: the send stays running here - // until a Stop or the session's end settles it. + // Answered, but not in a way Orca can read: the turn failed and the next send may go. + lane.apply( + lane.translator.promptFailed( + send.clientMessageId, + error instanceof Error ? error : new Error(String(error)), + this.deps.now() + ) + ) + this.accept(send.clientMessageId) + this.finish(send) return } const refusal = lane.translator.promptRefused(send.clientMessageId, error) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 4d230b1f3092..c949e51794c1 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -4,7 +4,7 @@ import type { ProviderTimelineEvent } from '../native-chat/agent-session-timelin import { acpNotificationEnvelopeSchema, AcpContextTimeline } from './acp-context-usage' import { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' import { GENERIC_ACP_DIALECT, type AcpDialect } from './acp-dialects/acp-dialect' -import type { AcpAgentError } from './acp-errors' +import { AcpAgentError } from './acp-errors' import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' import { translateAcpRequest } from './acp-timeline-requests' @@ -76,10 +76,12 @@ export class AcpTimelineTranslator { return this.finishPrompt(clientMessageId, result.stopReason, at) } - /** The agent's own error answer to the prompt; Orca's errors about it (a timeout, an unreadable - * answer, a closed connection) are no provider words and never reach here. */ - promptFailed(clientMessageId: string, error: AcpAgentError, at: number): ProviderTimelineEvent[] { - const detail = acpPromptErrorDetail(this.dialect, error) + /** The prompt failed: the agent's own error answer, or an answer Orca could not read (then no + * words are the agent's, and the row says only that the turn failed). A closed connection never + * reaches here. */ + promptFailed(clientMessageId: string, error: Error, at: number): ProviderTimelineEvent[] { + const detail = + error instanceof AcpAgentError ? acpPromptErrorDetail(this.dialect, error) : undefined const ended = this.prompts.last if (this.prompts.current?.clientMessageId !== clientMessageId) { // The provider already ended this turn; its answer may carry the only copy of the reason. From a652f152d81649c5d60cfe93b5dce55bf4db9214 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:14:26 -0700 Subject: [PATCH 254/269] fix(acp): send Grok's prompt-identity extension only to agents that echo it session/prompt carried _meta {promptId, requestId} for every ACP agent, though only Grok's dialect echoes it (injectedPromptIdentity). Now only an agent whose dialect declares it gets the extension; other ACP agents get a plain prompt. --- src/main/acp/acp-structured-session-adapter.test.ts | 11 +++++++++++ src/main/acp/acp-structured-turns.ts | 7 +++++-- src/main/acp/acp-timeline-translator.ts | 5 +++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index efe7a6f43a03..cfe8bf45d391 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -9,6 +9,7 @@ import { AgentSessionAcquisitionRefusal, AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { GENERIC_ACP_DIALECT } from './acp-dialects/acp-dialect' import { ACP_CHILD_ENV_TO_DELETE } from './acp-launch-specs' import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' import { @@ -131,6 +132,16 @@ it('reads those last words when the agent ends its stdout before its exit is see }) describe('ACP structured session adapter: turns', () => { + it("sends Grok's prompt identity only to an agent whose dialect echoes it", async () => { + const rig = await openAcpAdapterRig({ + deps: { spec: { ...GROK, dialect: GENERIC_ACP_DIALECT } } + }) + await rig.acquire() + await send(rig, 'send-1') + const prompt = await rig.frame('session/prompt') + expect(prompt.params).not.toHaveProperty('_meta') + }) + it('sends a prompt under an injected id and settles it on the agent first event', async () => { const rig = await openAcpAdapterRig() await rig.acquire() diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index df5d715d073a..2f2b683c9a25 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -155,8 +155,11 @@ export class AcpStructuredTurns { this.active = send const opened = lane.translator.openPrompt(send.clientMessageId, send.requestedAt) lane.apply(opened.events) - // The agent echoes this id on every event of the turn, so its rows join the turn Orca opened. - const meta = { promptId: opened.promptId, requestId: opened.promptId } + // An agent whose dialect echoes this id on every event of the turn gets it, so its rows join the + // turn Orca opened; no other agent is sent the extension. + const meta = lane.translator.injectsPromptIdentity + ? { promptId: opened.promptId, requestId: opened.promptId } + : undefined const answered = this.deps.connection.prompt(send.prompt, meta) if (this.steers.length > 0) { this.cancelForSteer() diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index c949e51794c1..30af2bf56444 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -57,6 +57,11 @@ export class AcpTimelineTranslator { ) } + /** Whether the agent's dialect echoes an injected prompt identity on the turn's events. */ + get injectsPromptIdentity(): boolean { + return this.dialect.injectedPromptIdentity === true + } + /** The host injects promptId as session/prompt._meta.promptId (and requestId). */ openPrompt( clientMessageId: string, From 2f7da7780c465328704dd1590c1c0ba711855d37 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:43:58 -0700 Subject: [PATCH 255/269] refactor(native-chat): the registered-agents capability lives in protocol-version again, as on main This reverts 0ef6d218157. That commit moved the capability to its own module only because main's protocol-version.ts was then one counted line over its limit; main now defines it there itself within the limit, and main's new restart test imports it from there. Main's test also reads the desktop's capability list as an older client; on this branch the desktop advertises registered agents, so its older client is that list without this one capability. --- src/main/ipc/desktop-renderer-runtime-capabilities.ts | 2 +- .../rpc/methods/agent-launch-caller-renders.test.ts | 6 ++++-- .../session-tab-registered-agent-projection.test.ts | 2 +- .../rpc/methods/structured-agent-session-policy.ts | 2 +- ...tructured-agent-session-restart-dismiss-fence.test.ts | 2 +- .../structured-agent-session-restart-resume.test.ts | 2 +- ...ured-agent-session-restart-unregistered-agent.test.ts | 5 ++++- .../src/lib/agent-launch-route-registered-agents.test.ts | 2 +- src/renderer/src/runtime/host-structured-agents.test.ts | 6 ++++-- src/renderer/src/runtime/host-structured-agents.ts | 2 +- .../electron-remote-runtime-client-capabilities.ts | 2 +- src/shared/protocol-version.ts | 9 ++++++++- ...uctured-agent-session-registered-agents-capability.ts | 8 -------- src/shared/structured-native-chat-launch-route.test.ts | 6 ++++-- src/shared/structured-native-chat-launch-route.ts | 2 +- .../cross-version-registered-agents.unit.test.ts | 2 +- 16 files changed, 34 insertions(+), 26 deletions(-) delete mode 100644 src/shared/structured-agent-session-registered-agents-capability.ts diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 6fe304ebef57..1c627e2618ca 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -7,10 +7,10 @@ import { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' -import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../shared/structured-agent-session-registered-agents-capability' import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../shared/agent-launch-runtime-capability' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shared/agent-session-background-task-child-views-capability' diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts b/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts index 712345ff8233..9ad8705529b6 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts @@ -7,8 +7,10 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/structured-agent-session-registered-agents-capability' +import { + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import type { RpcContext } from '../core' import { CAPABLE_CLIENT, diff --git a/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts index 692d8f783089..bc446197ca0f 100644 --- a/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/structured-agent-session-registered-agents-capability' import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' import { STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts index c23ea6889d8e..0892f35d7976 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -1,10 +1,10 @@ import { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../../shared/protocol-version' -import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/structured-agent-session-registered-agents-capability' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcContext } from '../core' diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts index ad2bc83c7d43..2750b8e62267 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts @@ -10,7 +10,7 @@ import { AgentSessionRecoveryCapsule } from '../../agent-session-recovery-capsule' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' -import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/structured-agent-session-registered-agents-capability' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { CLAUDE_STRUCTURED_AGENT } from '../../../claude/claude-structured-agent-definition' import { CODEX_STRUCTURED_AGENT } from '../../../codex/codex-structured-agent-definition' import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts index 93bf852d6759..5662731c9a1d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts @@ -8,7 +8,7 @@ import { restartRowsFor, type StructuredAgentSessionRestartAudience } from '../../../native-chat/agent-session-wire/structured-agent-session-restart-resume-set' -import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/structured-agent-session-registered-agents-capability' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' import { call, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts index c8154f530bd9..c66a6bcdf380 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts @@ -31,9 +31,12 @@ import { call, clearStructuredHostStub } from './structured-agent-session-rpc.te const NOW = 1_800_000_000_000 const FAILED = 'saved-grok-failure' const PENDING = 'saved-grok-offer' +// A desktop from before registered agents: today's list without that one capability. const OLD_CLIENT = { clientKind: 'runtime' as const, - clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES.filter( + (capability) => capability !== STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) } const NEW_CLIENT = { ...OLD_CLIENT, diff --git a/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts b/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts index 58044e274246..db5024bbb897 100644 --- a/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts +++ b/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../shared/protocol-version' -import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../shared/structured-agent-session-registered-agents-capability' import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' import type { TuiAgent } from '../../../shared/tui-agent' diff --git a/src/renderer/src/runtime/host-structured-agents.test.ts b/src/renderer/src/runtime/host-structured-agents.test.ts index 73f1bfa92371..41166e2c0e98 100644 --- a/src/renderer/src/runtime/host-structured-agents.test.ts +++ b/src/renderer/src/runtime/host-structured-agents.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../shared/electron-remote-runtime-client-capabilities' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' -import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../shared/structured-agent-session-registered-agents-capability' +import { + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' import type { RuntimeCapability } from '../../../shared/protocol-version' diff --git a/src/renderer/src/runtime/host-structured-agents.ts b/src/renderer/src/runtime/host-structured-agents.ts index af35cd2cc8f8..4196adff0336 100644 --- a/src/renderer/src/runtime/host-structured-agents.ts +++ b/src/renderer/src/runtime/host-structured-agents.ts @@ -4,7 +4,7 @@ import { type AgentSessionRegisteredAgent } from '../../../shared/agent-session-registered-agents' import { parseExecutionHostId } from '../../../shared/execution-host' -import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../shared/structured-agent-session-registered-agents-capability' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' import { callRuntimeRpc } from './runtime-rpc-client' diff --git a/src/shared/electron-remote-runtime-client-capabilities.ts b/src/shared/electron-remote-runtime-client-capabilities.ts index 817a73e3a203..17bdebceb79c 100644 --- a/src/shared/electron-remote-runtime-client-capabilities.ts +++ b/src/shared/electron-remote-runtime-client-capabilities.ts @@ -7,10 +7,10 @@ import { NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY } from './protocol-version' -import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from './structured-agent-session-registered-agents-capability' // Electron clients can decode client-hosted page placement; becoming a page // host still requires the separate authenticated browser-client lease. diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index deb4ef31b080..2b34d58bb051 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -29,7 +29,6 @@ export { } from './agent-session-resume-runtime-capabilities' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability' import { AGENT_SESSION_STOP_RUNTIME_CAPABILITIES } from './agent-session-stop-capabilities' -import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from './structured-agent-session-registered-agents-capability' import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' import { SKILL_BUNDLE_INSTALL_CAPABILITY, @@ -230,6 +229,14 @@ export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = // journal and lifecycle surfaces independently from Codex support. export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.claude.v1' as const +// Why: a host's structured agents are the ones it registered, not a list every build ships. A host +// advertising this accepts any agent it lists through `agentSession.agents` (with each agent's +// capability record) in `agentSession.*` params, and refuses one it did not register; a client +// offers an agent beyond Claude and Codex only to such a host. A client advertising it renders an +// `agent-session` tab of any agent its host lists; the host withholds every other agent's tabs from +// a client that does not (an older client would list them with an empty pane). +export const STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY = + 'agent-session.structured.registered-agents.v1' as const // Why: paired structured clients explicitly hold every visible session surface, allowing the host // to stop provider children after the last surface closes without tying lifetime to a transport. export const STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY = diff --git a/src/shared/structured-agent-session-registered-agents-capability.ts b/src/shared/structured-agent-session-registered-agents-capability.ts deleted file mode 100644 index 1f19a99e42e1..000000000000 --- a/src/shared/structured-agent-session-registered-agents-capability.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Why: a host's structured agents are the ones it registered, not a list every build ships. A host -// advertising this accepts any agent it lists through `agentSession.agents` (with each agent's -// capability record) in `agentSession.*` params, and refuses one it did not register; a client -// offers an agent beyond Claude and Codex only to such a host. A client advertising it renders an -// `agent-session` tab of any agent its host lists; the host withholds every other agent's tabs from -// a client that does not (an older client would list them with an empty pane). -export const STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY = - 'agent-session.structured.registered-agents.v1' as const diff --git a/src/shared/structured-native-chat-launch-route.test.ts b/src/shared/structured-native-chat-launch-route.test.ts index 6c4acfb26d18..cb4b48d3a9d5 100644 --- a/src/shared/structured-native-chat-launch-route.test.ts +++ b/src/shared/structured-native-chat-launch-route.test.ts @@ -4,8 +4,10 @@ */ import { describe, expect, it } from 'vitest' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from './protocol-version' -import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from './structured-agent-session-registered-agents-capability' +import { + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from './protocol-version' import { agentTabsDefaultToNativeChat, prefersStructuredNativeChatByDefault, diff --git a/src/shared/structured-native-chat-launch-route.ts b/src/shared/structured-native-chat-launch-route.ts index 9f24eab93a38..9f134bc95833 100644 --- a/src/shared/structured-native-chat-launch-route.ts +++ b/src/shared/structured-native-chat-launch-route.ts @@ -14,9 +14,9 @@ import type { GlobalSettings } from './global-settings-types' import type { ProjectExecutionRuntimeResolution } from './project-execution-runtime' import { STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from './protocol-version' -import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from './structured-agent-session-registered-agents-capability' import type { TuiAgent } from './tui-agent' import type { WorkspaceLaunchKind } from './workspace-launch-kind' diff --git a/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts index 8a07db9c1743..2f45ad6f345c 100644 --- a/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts @@ -28,9 +28,9 @@ import { import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' import { RUNTIME_CAPABILITIES, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' -import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../src/shared/structured-agent-session-registered-agents-capability' import type { RuntimeMobileSessionTabsSnapshot } from '../../../src/shared/runtime-types' import { resolveStructuredNativeChatSupport } from '../../../src/shared/structured-native-chat-launch-route' import { parseWorkspaceSession } from '../../../src/shared/workspace-session-schema' From b9728387fc4b693ae842e248c4979c675f3d6ffd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:44:00 -0700 Subject: [PATCH 256/269] test(acp): read the sign-in method with a schema, not a type assertion --- src/main/acp/acp-structured-auth.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/acp/acp-structured-auth.test.ts b/src/main/acp/acp-structured-auth.test.ts index 55d744051a42..21cdb88b9af0 100644 --- a/src/main/acp/acp-structured-auth.test.ts +++ b/src/main/acp/acp-structured-auth.test.ts @@ -2,6 +2,7 @@ // environment, else the sign-in it already cached; with neither, the chat reports it signed out. import { afterEach, describe, expect, it } from 'vitest' +import { z } from 'zod' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' @@ -15,6 +16,8 @@ afterEach(async () => { await closeProviderTimelineRigs() }) +const METHOD = z.object({ methodId: z.string() }) + const BOTH = [ { id: 'xai.api_key', name: 'API key' }, { id: 'cached_token', name: 'Cached sign-in' } @@ -51,9 +54,7 @@ async function signInWith(options: { script: requiresSignIn }) const acquired = await rig.acquire().catch((error: unknown) => error) - const methods = rig - .sent('authenticate') - .map((frame) => (frame.params as { methodId: string }).methodId) + const methods = rig.sent('authenticate').map((frame) => METHOD.parse(frame.params).methodId) return { rig, acquired, methods } } From 6aadfde778617d2af58a67d1f533f033934ef765 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:04:50 -0700 Subject: [PATCH 257/269] refactor(native-chat): composer transport and Stop control in their own modules Main's rewind change (#19338) brought NativeChatStructuredSession.tsx and use-structured-agent-session.ts to their line limits, leaving no room for this branch's image-acceptance and unpublished-Stop lines. The composer's transport (sends, commands, options, image acceptance) moves to use-native-chat-structured-composer-transport.ts, and whether Stop shows and what it does moves to structured-agent-session-stop-control.ts. Behavior is unchanged; the runtime cast on the composer's 'local' | 'remote' is now a typed return. --- .../NativeChatStructuredSession.tsx | 63 ++------------- .../structured-agent-session-stop-control.ts | 38 +++++++++ ...tive-chat-structured-composer-transport.ts | 80 +++++++++++++++++++ .../use-structured-agent-session.ts | 38 +++------ 4 files changed, 136 insertions(+), 83 deletions(-) create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 5def23ac67fe..1703aa684802 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -5,7 +5,6 @@ import { } from './native-chat-appearance-style' import { useMemo, useRef, useState } from 'react' import { agentSessionPromptQuestions } from '../../../../shared/agent-session-question-answer' -import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import type { NativeChatLiveSession } from './use-native-chat-live-session' import { NativeChatApprovalCard } from './NativeChatApprovalCard' @@ -35,7 +34,7 @@ import { NativeChatRewindContext } from './native-chat-rewind-context' import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' import { useAppStore } from '../../store' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' -import { useStructuredAgentAcceptsImages } from '@/runtime/use-host-structured-agent' +import { useNativeChatStructuredComposerTransport } from './use-native-chat-structured-composer-transport' import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' @@ -195,61 +194,15 @@ export function NativeChatStructuredSession( }) const questionBody = prompt?.body.kind === 'question' ? prompt.body : null const questions = questionBody ? agentSessionPromptQuestions(questionBody) : [] - const acceptsImages = useStructuredAgentAcceptsImages(props.target, props.agent) - const structuredTransport = useMemo(() => { - const threadGoal = controller.threadGoal - const setThreadGoalObjective = threadGoal - ? (objective: string) => threadGoal.change({ kind: 'set', objective }) - : null - return { - send: (text: string, attachments: readonly { id: string; path: string }[]): boolean => - sendThroughRelaunch(() => - controller.send( - text, - attachments.map((attachment) => ({ - path: attachment.path, - previewUri: attachment.path - })) - ) - ), - dispatchCommand: (text: string) => - dispatchStructuredAgentSessionComposerCommand(text, { - agent: props.agent, - snapshot: controller.optionSnapshot, - invokeAction: async (id) => { - setOptionPickerRequest((current) => ({ id, sequence: (current?.sequence ?? 0) + 1 })) - return true - }, - setOption: controller.setStructuredOption, - conversationCommands: controller.conversationCommands, - runConversationCommand: controller.runConversationCommand, - ...(setThreadGoalObjective ? { setThreadGoalObjective } : {}) - }), - ...(setThreadGoalObjective ? { threadGoal: { setObjective: setThreadGoalObjective } } : {}), - optionsSurface: controller.optionSurface, - conversationCommands: controller.conversationCommands, - optionSnapshot: controller.optionSnapshot, - optionPickerRequest, - sessionCommands: controller.sessionCommands, - acceptsImages, - contextUsage: controller.contextUsage, - worktreeId: fileLinkContext?.worktreeId, - onError: setComposerError, - runtime: (props.target.kind === 'local' ? 'local' : 'remote') as 'local' | 'remote', - sessionId: props.sessionId, - runtimeEnvironmentId: - props.target.kind === 'local' ? null : (props.target.environmentId ?? null) - } - }, [ - acceptsImages, + const structuredTransport = useNativeChatStructuredComposerTransport({ + props, controller, - fileLinkContext?.worktreeId, + sendThroughRelaunch, + worktreeId: fileLinkContext?.worktreeId, optionPickerRequest, - props.agent, - props.sessionId, - props.target, - sendThroughRelaunch - ]) + setOptionPickerRequest, + onError: setComposerError + }) return (

(input: { + /** The host has published this chat to this view. */ + published: boolean + /** The host takes a Stop naming no turn (and this view holds its fence). */ + stopsConversation: boolean + turnId: string | null + isWorking: boolean + /** This client still holds a send the host has not taken. */ + holdsUnsent: boolean + withdrawUnsent: () => void + cancel: (params: { turnId?: string }) => Promise +}): { canStop: boolean; stop: () => Promise } { + const { published, stopsConversation, turnId } = input + return { + // Before the host publishes this chat to this view, nothing sent has reached it: a Stop takes + // back what this client holds, so a start that never answers cannot hold the message hostage. + canStop: + turnId !== null || + (!published && input.holdsUnsent) || + (stopsConversation && (input.isWorking || input.holdsUnsent)), + stop: () => { + if (!published) { + input.withdrawUnsent() + return Promise.resolve(null) + } + if (stopsConversation) { + // Unsent text this client still owns goes back to its composer — a local move. + // Host-held drafts are never withdrawn by a Stop: the host pauses them and + // they stay visible as cards, on every device, until the user acts on one. + input.withdrawUnsent() + return input.cancel({}) + } + return turnId ? input.cancel({ turnId }) : Promise.resolve(null) + } + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts new file mode 100644 index 000000000000..8cc1e95fcf9b --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts @@ -0,0 +1,80 @@ +import { useMemo, type Dispatch, type SetStateAction } from 'react' +import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' +import { useStructuredAgentAcceptsImages } from '@/runtime/use-host-structured-agent' +import type { + NativeChatOptionPickerRequest, + NativeChatStructuredComposerTransport +} from './native-chat-composer-types' +import type { NativeChatStructuredViewProps } from './native-chat-view-types' +import type { useStructuredAgentSession } from './use-structured-agent-session' + +/** What the composer of a structured chat sends, picks and runs through, from the chat's controller. */ +export function useNativeChatStructuredComposerTransport(args: { + props: Pick + controller: ReturnType + sendThroughRelaunch: (send: () => boolean) => boolean + worktreeId: string | undefined + optionPickerRequest: NativeChatOptionPickerRequest | null + setOptionPickerRequest: Dispatch> + onError: (message: string | null) => void +}): NativeChatStructuredComposerTransport { + const { props, controller, sendThroughRelaunch, worktreeId, optionPickerRequest } = args + const { setOptionPickerRequest, onError } = args + const acceptsImages = useStructuredAgentAcceptsImages(props.target, props.agent) + return useMemo((): NativeChatStructuredComposerTransport => { + const threadGoal = controller.threadGoal + const setThreadGoalObjective = threadGoal + ? (objective: string) => threadGoal.change({ kind: 'set', objective }) + : null + return { + send: (text, attachments) => + sendThroughRelaunch(() => + controller.send( + text, + attachments.map((attachment) => ({ + path: attachment.path, + previewUri: attachment.path + })) + ) + ), + dispatchCommand: (text: string) => + dispatchStructuredAgentSessionComposerCommand(text, { + agent: props.agent, + snapshot: controller.optionSnapshot, + invokeAction: async (id) => { + setOptionPickerRequest((current) => ({ id, sequence: (current?.sequence ?? 0) + 1 })) + return true + }, + setOption: controller.setStructuredOption, + conversationCommands: controller.conversationCommands, + runConversationCommand: controller.runConversationCommand, + ...(setThreadGoalObjective ? { setThreadGoalObjective } : {}) + }), + ...(setThreadGoalObjective ? { threadGoal: { setObjective: setThreadGoalObjective } } : {}), + optionsSurface: controller.optionSurface, + conversationCommands: controller.conversationCommands, + optionSnapshot: controller.optionSnapshot, + optionPickerRequest, + sessionCommands: controller.sessionCommands, + acceptsImages, + contextUsage: controller.contextUsage, + worktreeId, + onError, + runtime: props.target.kind === 'local' ? 'local' : 'remote', + sessionId: props.sessionId, + runtimeEnvironmentId: + props.target.kind === 'local' ? null : (props.target.environmentId ?? null) + } + }, [ + acceptsImages, + controller, + worktreeId, + onError, + optionPickerRequest, + props.agent, + props.sessionId, + props.target, + sendThroughRelaunch, + setOptionPickerRequest + ]) +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 3351d28c6b2c..7b6de2d9c9e8 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -18,6 +18,7 @@ import { useStructuredAgentSessionHostStopsConversation } from '@/runtime/structured-agent-session-host-capability' import { hasUnsentStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal' +import { structuredAgentSessionStopControl } from './structured-agent-session-stop-control' import { legacyAgentSessionSelectedOptionId, type AgentSessionPromptResponse @@ -182,17 +183,6 @@ export function useStructuredAgentSession(args: { // Stop before a turn opens needs that form. An older host can stop only a turn it has opened. const stopsConversation = useStructuredAgentSessionHostStopsConversation(target) && transportState.fence !== null - // Before the host publishes this chat to this view, nothing sent has reached it: a Stop takes back - // what this client holds, so a start that never answers cannot hold the message hostage. - const stopsUnpublishedSend = - !transportEnabled && - hasUnsentStructuredAgentSessionOutboxEntry(outbox, transportState.submissions) - const canStop = - transportState.turnId !== null || - stopsUnpublishedSend || - (stopsConversation && - (transportState.isWorking || - hasUnsentStructuredAgentSessionOutboxEntry(outbox, transportState.submissions))) // A queued send is a card, never a transcript bubble. const isWorking = transportState.isWorking const transcriptOutbox = useMemo( @@ -273,23 +263,15 @@ export function useStructuredAgentSession(args: { turnActivity: transportState.turnActivity, backgroundTasks: transportState.backgroundTasks, turnId: transportState.turnId, - canStop, - stop: () => { - if (!transportEnabled) { - outboxController.withdrawUnsent() - return Promise.resolve(null) - } - if (stopsConversation) { - // Unsent text this client still owns goes back to its composer — a local move. - // Host-held drafts are never withdrawn by a Stop: the host pauses them and - // they stay visible as cards, on every device, until the user acts on one. - outboxController.withdrawUnsent() - return mutate('agentSession.cancel', 'agentSession.cancel', {}) - } - return transportState.turnId - ? mutate('agentSession.cancel', 'agentSession.cancel', { turnId: transportState.turnId }) - : Promise.resolve(null) - }, + ...structuredAgentSessionStopControl({ + published: transportEnabled, + stopsConversation, + turnId: transportState.turnId, + isWorking: transportState.isWorking, + holdsUnsent: hasUnsentStructuredAgentSessionOutboxEntry(outbox, transportState.submissions), + withdrawUnsent: outboxController.withdrawUnsent, + cancel: (params) => mutate('agentSession.cancel', 'agentSession.cancel', params) + }), queuedMessages: queuedController, cancel: async (turnId: string, prompt?: StructuredPromptCancelTarget) => { // Capability negotiation must complete before mutate fingerprints the payload: From 06dd7eae3de721b225ea72a15a50d4ec9a39580f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:17:17 -0700 Subject: [PATCH 258/269] test(native-chat): read registered agents by id, as main's structuredAgentsReadBy now takes Main's A3 squash changed structuredAgentsReadBy to take agent ids; this branch's test still passed { agent } objects (CI typecheck TS2322). --- src/main/ipc/desktop-renderer-runtime-capabilities.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 13db4584eb42..13bba9278bda 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -111,9 +111,9 @@ describe('desktop renderer runtime client capabilities', () => { ] as const)('reads every agent %s registered', (_host, clientCapabilities) => { expect( structuredAgentsReadBy({ clientKind: 'runtime', clientCapabilities }, [ - { agent: 'claude' }, - { agent: 'codex' }, - { agent: 'grok' } + 'claude', + 'codex', + 'grok' ]) ).toBeUndefined() }) From ccc2a8d8b05f623744e1c591e553208d39de339f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:06:57 -0700 Subject: [PATCH 259/269] fix(acp): a first reopen warns when Grok forgets a chat that exchanged turns A Grok session the chat created was treated as one Grok never saved, so when Grok reported it missing on the chat's first reopen, Orca swapped in a fresh session silently, even after completed exchanges: the person saw the old messages while Grok had forgotten them. The launch now counts a created session as never saved only when the chat's journal, read at the failed reopen, holds no turn of that Grok session; anything else, an unreadable journal included, takes the normal path: the fresh session is recorded as replacing the old one and the one warning row is written. --- src/main/acp/acp-session-reopen-failure.ts | 7 +- src/main/acp/acp-structured-auth.test.ts | 2 +- ...acp-structured-host-restore-failed.test.ts | 162 +++++++++++++----- .../acp/acp-structured-host.test-support.ts | 7 +- .../acp-structured-launch-resolution.test.ts | 80 ++++++++- .../acp/acp-structured-launch-resolution.ts | 42 ++++- ...-structured-session-adapter-resume.test.ts | 18 +- .../acp-structured-session-adapter.test.ts | 2 +- .../provider-timeline-identity.ts | 5 + .../structured-agent-runtime-registrations.ts | 4 + .../structured-agent-session-runtime.ts | 1 + 11 files changed, 265 insertions(+), 65 deletions(-) diff --git a/src/main/acp/acp-session-reopen-failure.ts b/src/main/acp/acp-session-reopen-failure.ts index dd990e3c9289..e99ae641c599 100644 --- a/src/main/acp/acp-session-reopen-failure.ts +++ b/src/main/acp/acp-session-reopen-failure.ts @@ -1,6 +1,7 @@ // What takes over when an ACP agent cannot reopen the session a chat proved. A session this chat -// created and the agent never saved is superseded: there was nothing to remember. Any other is -// replaced by a new session that names it, and the chat says once that the agent forgot. +// created and never exchanged a turn on, which the agent says it does not hold, is superseded: +// there was nothing to remember. Any other is replaced by a new session that names it, and the +// chat says once that the agent forgot. import { agentSessionFailureFact } from '../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' @@ -26,7 +27,7 @@ export function acpReopenTakeover( if ( error instanceof AcpRpcError && error.code === ACP_RESOURCE_NOT_FOUND && - resume.mayBeUnsaved + resume.mayBeUnsaved() ) { return { supersedesKey: resume.key } } diff --git a/src/main/acp/acp-structured-auth.test.ts b/src/main/acp/acp-structured-auth.test.ts index 21cdb88b9af0..8a71bda700a7 100644 --- a/src/main/acp/acp-structured-auth.test.ts +++ b/src/main/acp/acp-structured-auth.test.ts @@ -47,7 +47,7 @@ async function signInWith(options: { launch: { env: options.env, ...(options.resume - ? { resume: { sessionId: PROVIDER_SESSION, key: 'acp-key', mayBeUnsaved: false } } + ? { resume: { sessionId: PROVIDER_SESSION, key: 'acp-key', mayBeUnsaved: () => false } } : {}) }, initialize: { authMethods: options.authMethods }, diff --git a/src/main/acp/acp-structured-host-restore-failed.test.ts b/src/main/acp/acp-structured-host-restore-failed.test.ts index 19ede7dace52..7304b650b595 100644 --- a/src/main/acp/acp-structured-host-restore-failed.test.ts +++ b/src/main/acp/acp-structured-host-restore-failed.test.ts @@ -1,6 +1,6 @@ -// A saved Grok session Grok cannot reopen, through the real host, record store and launch +// A Grok session Grok cannot reopen, through the real host, record store, journal and launch // resolver: a fresh session continues the chat, the chain records what it replaced, and the chat -// says so once. +// says so once, unless nothing was ever exchanged on the lost session. import { afterEach, describe, expect, it } from 'vitest' import { z } from 'zod' @@ -15,6 +15,7 @@ import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/ import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' import { GROK, GROK_CONFIG_OPTIONS, PROVIDER_SESSION } from './acp-structured-adapter.test-support' import { ACP_HANDLE_TRANSPORT } from './acp-structured-agent-definitions' +import { replayJournal } from '../native-chat/agent-session-journal/journal-open' import { createAcpStructuredLaunchResolver } from './acp-structured-launch-resolution' import { attachParams, @@ -32,8 +33,8 @@ const FRESH = 'grok-fresh-session' const keyOf = (nativeId: string) => agentSessionProviderHandleKey({ transport: ACP_HANDLE_TRANSPORT, agent: 'grok', nativeId }) -/** Grok's first session opens, then cannot be reopened; every session after it reopens. */ -function firstSessionLost(loads: string[]) { +/** Grok's first session opens, then cannot be reopened with `code`; every session after it reopens. */ +function firstSessionLost(loads: string[], code: number, message: string) { let opened = 0 return (agent: AcpScriptedAgent) => { agent.on('session/new', (frame) => { @@ -47,7 +48,7 @@ function firstSessionLost(loads: string[]) { const { sessionId } = z.object({ sessionId: z.string() }).parse(frame.params) loads.push(sessionId) if (sessionId === PROVIDER_SESSION) { - agent.fail(frame, -32603, 'session file is corrupt') + agent.fail(frame, code, message) } else { agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) } @@ -55,38 +56,62 @@ function firstSessionLost(loads: string[]) { } } -describe('a saved Grok session Grok cannot reopen', () => { - it('continues the chat in a fresh session that the chain records and the next reopen loads', async () => { - const loads: string[] = [] - const resolver: { resolve: ReturnType | null } = { - resolve: null - } - const { rig, host, store, fence } = await openHostRig({ - initialize: RESUMES, - script: firstSessionLost(loads), - deps: { - resolveLaunch: (input) => { - if (!resolver.resolve) { - throw new Error('launch resolver not ready') - } - return resolver.resolve(input) +async function openRestoreRig(code: number, message: string) { + const loads: string[] = [] + const resolver: { resolve: ReturnType | null } = { + resolve: null + } + const opened = await openHostRig({ + initialize: RESUMES, + script: firstSessionLost(loads, code, message), + deps: { + resolveLaunch: (input) => { + if (!resolver.resolve) { + throw new Error('launch resolver not ready') } + return resolver.resolve(input) } - }) - resolver.resolve = createAcpStructuredLaunchResolver(GROK, { - store, - resolveWorkspacePath: async () => '/workspace', - resolveEnvironment: async () => ({ PATH: '/usr/bin' }), - resolveCommand: () => '/fake/grok' - }) - const warnings = async () => { - await host.flushStreamedEvents(SESSION) - return (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items.filter( - (row) => - row.body.kind === 'status' && - readAgentSessionFailureFact(row.body.failure)?.kind === 'sessionNotRestored' - ) } + }) + const { rig, host, store, journalDatabase } = opened + resolver.resolve = createAcpStructuredLaunchResolver(GROK, { + store, + readJournal: (sessionId) => replayJournal(journalDatabase.db, sessionId), + resolveWorkspacePath: async () => '/workspace', + resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + resolveCommand: () => '/fake/grok' + }) + const warnings = async () => { + await host.flushStreamedEvents(SESSION) + return (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items.filter( + (row) => + row.body.kind === 'status' && + readAgentSessionFailureFact(row.body.failure)?.kind === 'sessionNotRestored' + ) + } + /** A send Grok answers in `providerSession`, ending its turn. */ + const exchange = async (text: string, reply: string, providerSession: string) => { + await send(host, text) + const prompt = await rig.frame('session/prompt') + expect(prompt.params).toMatchObject({ sessionId: providerSession }) + rig.child().agent.notify('session/update', { + sessionId: providerSession, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: reply } }, + _meta: { promptId: promptIdOf(prompt) } + }) + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + await rig.settle() + await host.flushStreamedEvents(SESSION) + } + return { ...opened, loads, warnings, exchange } +} + +describe('a saved Grok session Grok cannot reopen', () => { + it('continues the chat in a fresh session that the chain records and the next reopen loads', async () => { + const { host, store, fence, loads, warnings, exchange } = await openRestoreRig( + -32603, + 'session file is corrupt' + ) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) await host.close(SESSION, 'user-close') @@ -109,17 +134,7 @@ describe('a saved Grok session Grok cannot reopen', () => { }) // The chat keeps working in the fresh session. - await send(host, 'still there?') - const prompt = await rig.frame('session/prompt') - expect(prompt.params).toMatchObject({ sessionId: FRESH }) - rig.child().agent.notify('session/update', { - sessionId: FRESH, - update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'yes' } }, - _meta: { promptId: promptIdOf(prompt) } - }) - rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) - await rig.settle() - await host.flushStreamedEvents(SESSION) + await exchange('still there?', 'yes', FRESH) const messages = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items .filter((row) => row.body.kind === 'message') .map((row) => (row.body.kind === 'message' ? row.body.blocks : [])) @@ -139,3 +154,60 @@ describe('a saved Grok session Grok cannot reopen', () => { await host.close(SESSION, 'user-close') }) }) + +describe('a created Grok session Grok reports missing on the first reopen', () => { + it('replaces it and says so once when the chat exchanged a turn on it', async () => { + const { host, store, fence, loads, warnings, exchange, messages } = await openRestoreRig( + -32002, + 'Resource not found' + ) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await exchange('remember 42', 'noted', PROVIDER_SESSION) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + + expect(loads).toEqual([PROVIDER_SESSION]) + const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] + expect(chain).toHaveLength(2) + const head = agentSessionProviderHandleChainHead(chain) + expect(head).toMatchObject({ + origin: 'created', + handle: { nativeId: FRESH }, + replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' } + }) + expect(head?.supersedesKey).toBeUndefined() + expect(await warnings()).toHaveLength(1) + // The person still sees what Grok forgot. + expect(await messages()).toEqual(expect.arrayContaining(['remember 42', 'noted'])) + + // The next reopen loads the fresh session, and says nothing new. + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) + + it('supersedes it silently when nothing was exchanged on it', async () => { + const { host, store, fence, loads, warnings } = await openRestoreRig( + -32002, + 'Resource not found' + ) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + + expect(loads).toEqual([PROVIDER_SESSION]) + const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] + expect(chain).toHaveLength(1) + const head = agentSessionProviderHandleChainHead(chain) + expect(head).toMatchObject({ + origin: 'created', + handle: { nativeId: FRESH }, + supersedesKey: keyOf(PROVIDER_SESSION) + }) + expect(head?.replaces).toBeUndefined() + expect(await warnings()).toEqual([]) + await host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-host.test-support.ts b/src/main/acp/acp-structured-host.test-support.ts index 74b50e5c5dde..6d04f5eddf7e 100644 --- a/src/main/acp/acp-structured-host.test-support.ts +++ b/src/main/acp/acp-structured-host.test-support.ts @@ -70,7 +70,7 @@ export function launch(resume: () => boolean): () => Promise false } : null }) @@ -88,6 +88,7 @@ export async function openHostRig( let generation = 0 // As the runtime wires it: every exit and late send settlement the adapter observes reaches the host. const hosted: { host: StructuredAgentSessionHost | null } = { host: null } + const journalDatabase = openTestJournalHostDatabase(state.root) const rig = await openAcpAdapterRig({ ...options, deps: { @@ -106,7 +107,7 @@ export async function openHostRig( logger: state.log.logger, store, adapter: rig.adapter, - journalDatabase: openTestJournalHostDatabase(state.root), + journalDatabase, recoveryCapsule: new AgentSessionRecoveryCapsule(state.root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', @@ -140,7 +141,7 @@ export async function openHostRig( await rig.settle() await host.flushStreamedEvents(SESSION) } - return { rig, host, store, fence, messages, exchange } + return { rig, host, store, journalDatabase, fence, messages, exchange } } /** Grok's capabilities: it loads and resumes sessions; Orca reopens with `session/load`. */ diff --git a/src/main/acp/acp-structured-launch-resolution.test.ts b/src/main/acp/acp-structured-launch-resolution.test.ts index f99b4a555433..d9129b2ff9a1 100644 --- a/src/main/acp/acp-structured-launch-resolution.test.ts +++ b/src/main/acp/acp-structured-launch-resolution.test.ts @@ -5,6 +5,9 @@ import { } from '../../shared/agent-session-provider-handle' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' +import { createJournalReducerState } from '../native-chat/agent-session-journal/journal-reducer' +import { spellProviderTimelineKey } from '../native-chat/agent-session-timeline/provider-timeline-identity' import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor' import { ACP_CHILD_ENV_TO_DELETE, acpLaunchSpecFor } from './acp-launch-specs' import { createAcpStructuredLaunchResolver } from './acp-structured-launch-resolution' @@ -28,12 +31,36 @@ function grokRecord(overrides: Partial = {}): AgentSessionRe } } -function resolver(record: AgentSessionRecord, fullAccess = false) { +/** A journal holding one turn of each named provider session. */ +function journalWithTurns(...providerSessions: string[]): JournalLoad { + const state = createJournalReducerState(identity.sessionId, 'epoch-1') + providerSessions.forEach((providerSession, index) => { + const turnId = spellProviderTimelineKey(providerSession, { + source: 'provider', + value: `prompt:m${index}` + }) + state.items.set(`turn-${index}`, { + itemId: `turn-${index}`, + revision: 1, + sequence: index + 2, + observedAt: 1, + body: { kind: 'turn', turnId, state: 'completed' } + }) + }) + return { state, newer: null, damage: null } +} + +function resolver( + record: AgentSessionRecord, + fullAccess = false, + readJournal: () => JournalLoad | null = () => null +) { const searched: (string | null | undefined)[] = [] return { searched, resolve: createAcpStructuredLaunchResolver(GROK, { store: { getRecord: () => record }, + readJournal, resolveWorkspacePath: async () => '/repo/worktree', resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), resolveLaunchEnv: () => ({ GROK_EXTRA: '1' }), @@ -86,11 +113,56 @@ describe('ACP launch resolution', () => { nativeId: 'acp-1' } const key = agentSessionProviderHandleKey(handle) - expect(created.resume).toEqual({ sessionId: 'acp-1', key, mayBeUnsaved: true }) + expect(created.resume).toMatchObject({ sessionId: 'acp-1', key }) + expect(created.resume?.mayBeUnsaved()).toBe(true) const resumed = await resolver( - grokRecord({ providerHandleChain: [link('created'), link('resumed')] }) + grokRecord({ providerHandleChain: [link('created'), link('resumed')] }), + false, + () => journalWithTurns() ).resolve({ identity }) - expect(resumed.resume).toEqual({ sessionId: 'acp-1', key, mayBeUnsaved: false }) + expect(resumed.resume).toMatchObject({ sessionId: 'acp-1', key }) + expect(resumed.resume?.mayBeUnsaved()).toBe(false) + }) + + it('counts a created session as possibly unsaved only while the journal proves no turn on it', async () => { + const created = grokRecord({ + providerHandleChain: [ + { + linkId: 'link-created', + origin: 'created', + mintedAtFence: 1, + observedAt: 1, + handle: { transport: 'acp', agent: 'grok', nativeId: 'acp-1' } + } + ] + }) + const unsaved = async (readJournal: () => JournalLoad | null) => + (await resolver(created, false, readJournal).resolve({ identity })).resume?.mayBeUnsaved() + const reads: string[] = [] + const launch = await resolver(created, false, () => { + reads.push('read') + return null + }).resolve({ identity }) + // Read only when asked: a reopen that works never replays the journal. + expect(reads).toEqual([]) + expect(launch.resume?.mayBeUnsaved()).toBe(true) + + expect(await unsaved(() => journalWithTurns())).toBe(true) + expect(await unsaved(() => journalWithTurns('acp-other'))).toBe(true) + expect(await unsaved(() => journalWithTurns('acp-other', 'acp-1'))).toBe(false) + // A journal that does not read whole, or at all, proves nothing. + expect( + await unsaved(() => ({ + ...journalWithTurns(), + damage: { sequence: 3, cause: 'sequence-gap' } + })) + ).toBe(false) + expect(await unsaved(() => ({ ...journalWithTurns(), newer: { sequence: 3 } }))).toBe(false) + expect( + await unsaved(() => { + throw new Error('journal_closed') + }) + ).toBe(false) }) it('refuses a record pinned to another host or another agent', async () => { diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index af2747803ffb..07ffc2e006d9 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -13,6 +13,9 @@ import { } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' +import { isProviderTimelineTurnInNamespace } from '../native-chat/agent-session-timeline/provider-timeline-identity' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import type { AcpLaunchSpec } from './acp-launch-specs' @@ -28,13 +31,16 @@ export type AcpStructuredLaunch = { sessionId: string /** The chain key of the session to load, which a fresh session names if it takes over. */ key: string - /** Only a session this chat created may be one the agent never saved, and so be superseded. */ - mayBeUnsaved: boolean + /** Only a session this chat created and never exchanged a turn on may be one the agent never + * saved, and so be superseded. Read only when the agent cannot reopen it. */ + mayBeUnsaved: () => boolean } | null } export type AcpStructuredLaunchResolverDeps = { store: Pick + /** The chat's journal as it stands, read without opening it; null when it has none. */ + readJournal: (sessionId: string) => JournalLoad | null resolveWorkspacePath: (workspaceId: string) => Promise /** The shared base env; re-read per acquisition. */ resolveEnvironment: () => Promise> @@ -98,9 +104,39 @@ export function createAcpStructuredLaunchResolver( ? { sessionId: head.handle.nativeId, key: agentSessionProviderHandleKey(head.handle), - mayBeUnsaved: head.origin === 'created' + mayBeUnsaved: () => + head.origin === 'created' && + nothingExchangedOn(deps.readJournal, identity.sessionId, head.handle.nativeId) } : null } } } + +/** Proof the chat holds no turn of provider session `nativeId`; a journal that does not read whole, + * or does not read at all, proves nothing. */ +function nothingExchangedOn( + readJournal: AcpStructuredLaunchResolverDeps['readJournal'], + sessionId: string, + nativeId: string +): boolean { + let load: JournalLoad | null + try { + load = readJournal(sessionId) + } catch { + return false + } + if (!load) { + return true + } + if (load.damage || load.newer) { + return false + } + for (const item of load.state.items.values()) { + const turn = readAgentJournalTurn(item.body) + if (turn && isProviderTimelineTurnInNamespace(turn.turnId, nativeId)) { + return false + } + } + return true +} diff --git a/src/main/acp/acp-structured-session-adapter-resume.test.ts b/src/main/acp/acp-structured-session-adapter-resume.test.ts index abbbdd3b5529..58d402896c76 100644 --- a/src/main/acp/acp-structured-session-adapter-resume.test.ts +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -34,7 +34,9 @@ const hello: AgentJournalMessageItem = { /** Grok's own capabilities: it loads and resumes sessions; Orca reopens with `session/load`. */ const RESUMES = { agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } } } const resume = { - launch: { resume: { sessionId: PROVIDER_SESSION, key: 'acp:grok:saved', mayBeUnsaved: false } } + launch: { + resume: { sessionId: PROVIDER_SESSION, key: 'acp:grok:saved', mayBeUnsaved: () => false } + } } /** Everything Grok may send while it reattaches: its saved exchange marked as replay, a task the @@ -278,7 +280,7 @@ describe('reattaching a Grok chat the journal holds', () => { it('starts a new session in place of a created one that session/load reports missing', async () => { const rig = await openAcpAdapterRig({ launch: { - resume: { sessionId: 'never-saved', key: 'acp:grok:never-saved', mayBeUnsaved: true } + resume: { sessionId: 'never-saved', key: 'acp:grok:never-saved', mayBeUnsaved: () => true } }, initialize: RESUMES, script: (agent) => @@ -305,7 +307,9 @@ describe('reattaching a Grok chat the journal holds', () => { 'continues %s it cannot reopen in a new one, says so once, and keeps working', async (_label, mayBeUnsaved, code, message) => { const rig = await openAcpAdapterRig({ - launch: { resume: { sessionId: 'saved-1', key: 'acp-key-saved-1', mayBeUnsaved } }, + launch: { + resume: { sessionId: 'saved-1', key: 'acp-key-saved-1', mayBeUnsaved: () => mayBeUnsaved } + }, initialize: RESUMES, script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, code, message)) }) @@ -328,7 +332,9 @@ describe('reattaching a Grok chat the journal holds', () => { it('refuses a signed-out agent at reopen rather than replacing its session', async () => { const rig = await openAcpAdapterRig({ - launch: { resume: { sessionId: 'saved-1', key: 'acp-key-saved-1', mayBeUnsaved: false } }, + launch: { + resume: { sessionId: 'saved-1', key: 'acp-key-saved-1', mayBeUnsaved: () => false } + }, initialize: RESUMES, script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, -32000, 'Authentication required')) @@ -343,7 +349,9 @@ describe('reattaching a Grok chat the journal holds', () => { const start = new AbortController() const logger = { warn: vi.fn(), error: vi.fn() } const rig = await openAcpAdapterRig({ - launch: { resume: { sessionId: 'saved-1', key: 'acp-key-saved-1', mayBeUnsaved: false } }, + launch: { + resume: { sessionId: 'saved-1', key: 'acp-key-saved-1', mayBeUnsaved: () => false } + }, initialize: RESUMES, deps: { logger }, script: (agent) => diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index cfe8bf45d391..42667674dc82 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -80,7 +80,7 @@ describe('ACP structured session adapter: acquire', () => { it('replaces a created session the agent never saved with a new one', async () => { const rig = await openAcpAdapterRig({ - launch: { resume: { sessionId: 'lost', key: 'old-key', mayBeUnsaved: true } }, + launch: { resume: { sessionId: 'lost', key: 'old-key', mayBeUnsaved: () => true } }, script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Session not found')) }) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts index 6dd47f03ec53..0fa855cc0acd 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts @@ -76,6 +76,11 @@ export function spellProviderTimelineKey( : `m:${providerTimelineKeyPart(key.value)}` } +/** Whether a turn id names a turn the provider keyed inside session `namespace`. */ +export function isProviderTimelineTurnInNamespace(turnId: string, namespace: string): boolean { + return turnId.startsWith(`p:${providerTimelineKeyPart(namespace)}:`) +} + /** The existing `legacy` arm. Turn rows keep the `turn-lifecycle:` record prefix the other lanes * write; provider keys are spelled inside their namespace, and apart from minted ones. */ export function createLegacyProviderTimelineIdentityScheme(input: { diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 23ee50706dd4..6fe5b2518a26 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -17,6 +17,8 @@ import type { StructuredAgentDefinition } from '../native-chat/agent-session-wir import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { readClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { replayJournal } from '../native-chat/agent-session-journal/journal-open' import type { AgentSessionRecordStore } from './agent-session-record-store' import type { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups' import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' @@ -39,6 +41,7 @@ import { AcpStructuredSessionAdapter } from '../acp/acp-structured-session-adapt export type StructuredAgentAdapterContext = { deps: StructuredAgentSessionRuntimeDeps store: AgentSessionRecordStore + journalDatabase: JournalHostDatabase environment: ReturnType /** Hands the host an exit or other lifecycle event the agent observed. */ deliverLifecycle: (event: StructuredAgentSessionLifecycleEvent) => void @@ -162,6 +165,7 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio spec, resolveLaunch: createAcpStructuredLaunchResolver(spec, { store, + readJournal: (sessionId) => replayJournal(context.journalDatabase.db, sessionId), resolveWorkspacePath: deps.resolveWorkspacePath, resolveEnvironment: context.environment.resolveBaseEnvironment, ...(deps.resolveAgentLaunchEnv ? { resolveLaunchEnv: deps.resolveAgentLaunchEnv } : {}), diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 8389d09d1594..ef123ba785ef 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -260,6 +260,7 @@ async function installOnJournal( const context: StructuredAgentAdapterContext = { deps, store, + journalDatabase, environment: envResolvers, deliverLifecycle: lifecycle.deliver, followUps: createStructuredAgentSessionDispatchFollowUps({ From b1949948625def42b0e16ad06dbb6ed62ae2093e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:34:56 -0700 Subject: [PATCH 260/269] fix(acp): a start writes the warning row an earlier attach failure dropped The row saying Grok forgot the chat was written only into the attach's deferred sink, while the fresh session's link was saved earlier. An attach failure, quit or crash in between dropped the row forever. Every start now derives the owed rows: each conversation the chain says was lost to a failed restore gets its row unless the chat's journal already holds it. The row now names the lost conversation rather than the fresh session, so a row an unused replacement wrote still counts after Grok supersedes it. --- src/main/acp/acp-session-reopen-failure.ts | 13 +- src/main/acp/acp-structured-acquire.ts | 12 +- src/main/acp/acp-structured-auth.test.ts | 9 +- ...acp-structured-host-restore-failed.test.ts | 111 ++++++++++++++++-- .../acp/acp-structured-host.test-support.ts | 3 +- .../acp-structured-launch-resolution.test.ts | 88 +++++++++++++- .../acp/acp-structured-launch-resolution.ts | 56 ++++++++- ...-structured-session-adapter-resume.test.ts | 35 +++++- .../acp-structured-session-adapter.test.ts | 9 +- .../provider-timeline-identity.ts | 11 ++ 10 files changed, 318 insertions(+), 29 deletions(-) diff --git a/src/main/acp/acp-session-reopen-failure.ts b/src/main/acp/acp-session-reopen-failure.ts index e99ae641c599..90c7442d24b6 100644 --- a/src/main/acp/acp-session-reopen-failure.ts +++ b/src/main/acp/acp-session-reopen-failure.ts @@ -1,7 +1,8 @@ // What takes over when an ACP agent cannot reopen the session a chat proved. A session this chat // created and never exchanged a turn on, which the agent says it does not hold, is superseded: // there was nothing to remember. Any other is replaced by a new session that names it, and the -// chat says once that the agent forgot. +// chat says once that the agent forgot: the row names the lost conversation, so a later start +// can tell whether it was ever written. import { agentSessionFailureFact } from '../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' @@ -35,15 +36,21 @@ export function acpReopenTakeover( return { replaces: { key: resume.key, reason: 'restore-failed', replacedAt: start.now } } } -/** The one row a replaced session writes: the agent no longer remembers what came before. */ +/** The provider item key of the row saying the conversation under chain key `lostKey` was lost. */ +export function acpSessionNotRestoredItem(lostKey: string): string { + return `session-not-restored:${lostKey}` +} + +/** The one row a lost conversation gets: the agent no longer remembers what came before. */ export function acpSessionNotRestoredRow( + lostKey: string, providerSessionId: string, agentName: string ): ProviderTimelineEvent[] { return [ { type: 'item.update', - item: `session-not-restored:${providerSessionId}`, + item: acpSessionNotRestoredItem(lostKey), body: { kind: 'status', tone: 'warning', diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 0cdeeea3a782..b96296910263 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -1,7 +1,8 @@ // Making a reservation real for an ACP agent: open its connection (which spawns and owns the // process), record it before any handshake, initialize with the client's file system and terminals // off, then reattach the session this chat proved with `session/load` (`session/resume` only for an -// agent that cannot load) or start a new one; a saved session the agent cannot reopen is replaced by a new one, with a warning row. The +// agent that cannot load) or start a new one. A saved session the agent cannot reopen is replaced +// by a new one, with a warning row that any later start writes if this attach never did. The // journal already holds a reattached chat, so whatever the agent sends while it reattaches is not // written, except context usage. The handshake has no time bound: the acquire's abort signal // (Close, Stop, quit) stops it at any point. @@ -305,8 +306,13 @@ export async function acquireAcpStructuredSession(input: { if (connection.exited || connection.closed) { throw new Error(connection.stderrTail() || `${spec.command} exited while starting`) } - if (takeover?.replaces) { - liveLane.apply(acpSessionNotRestoredRow(started.sessionId, agentName)) + // The chat says once per lost conversation that the agent forgot it, this start's loss included. + const lost = [ + ...(launch.resume?.unannouncedLosses() ?? []), + ...(takeover?.replaces ? [takeover.replaces.key] : []) + ] + for (const key of lost) { + liveLane.apply(acpSessionNotRestoredRow(key, started.sessionId, agentName)) } return { acquisition: { process, link, acquisitionGeneration: generation }, session } } catch (error) { diff --git a/src/main/acp/acp-structured-auth.test.ts b/src/main/acp/acp-structured-auth.test.ts index 8a71bda700a7..92f27513cd51 100644 --- a/src/main/acp/acp-structured-auth.test.ts +++ b/src/main/acp/acp-structured-auth.test.ts @@ -47,7 +47,14 @@ async function signInWith(options: { launch: { env: options.env, ...(options.resume - ? { resume: { sessionId: PROVIDER_SESSION, key: 'acp-key', mayBeUnsaved: () => false } } + ? { + resume: { + sessionId: PROVIDER_SESSION, + key: 'acp-key', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } + } : {}) }, initialize: { authMethods: options.authMethods }, diff --git a/src/main/acp/acp-structured-host-restore-failed.test.ts b/src/main/acp/acp-structured-host-restore-failed.test.ts index 7304b650b595..519d19e6ce70 100644 --- a/src/main/acp/acp-structured-host-restore-failed.test.ts +++ b/src/main/acp/acp-structured-host-restore-failed.test.ts @@ -1,8 +1,9 @@ // A Grok session Grok cannot reopen, through the real host, record store, journal and launch // resolver: a fresh session continues the chat, the chain records what it replaced, and the chat -// says so once, unless nothing was ever exchanged on the lost session. +// says so once, unless nothing was ever exchanged on the lost session. A start that succeeds +// writes the row an earlier attach failure dropped. -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' import { @@ -30,25 +31,28 @@ afterEach(async () => { }) const FRESH = 'grok-fresh-session' +const FRESH_2 = 'grok-second-fresh-session' const keyOf = (nativeId: string) => agentSessionProviderHandleKey({ transport: ACP_HANDLE_TRANSPORT, agent: 'grok', nativeId }) -/** Grok's first session opens, then cannot be reopened with `code`; every session after it reopens. */ -function firstSessionLost(loads: string[], code: number, message: string) { - let opened = 0 +type Lost = Map + +/** Grok opens sessions in turn and cannot reopen any session in `lost`; it reopens every other. */ +function sessionsLost(loads: string[], lost: Lost) { + const opened = [PROVIDER_SESSION, FRESH, FRESH_2] return (agent: AcpScriptedAgent) => { agent.on('session/new', (frame) => { - opened += 1 agent.reply(frame, { - sessionId: opened === 1 ? PROVIDER_SESSION : FRESH, + sessionId: opened.shift() ?? 'grok-unexpected-session', configOptions: GROK_CONFIG_OPTIONS }) }) agent.on('session/load', (frame) => { const { sessionId } = z.object({ sessionId: z.string() }).parse(frame.params) loads.push(sessionId) - if (sessionId === PROVIDER_SESSION) { - agent.fail(frame, code, message) + const failure = lost.get(sessionId) + if (failure) { + agent.fail(frame, failure.code, failure.message) } else { agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) } @@ -58,12 +62,13 @@ function firstSessionLost(loads: string[], code: number, message: string) { async function openRestoreRig(code: number, message: string) { const loads: string[] = [] + const lost: Lost = new Map([[PROVIDER_SESSION, { code, message }]]) const resolver: { resolve: ReturnType | null } = { resolve: null } const opened = await openHostRig({ initialize: RESUMES, - script: firstSessionLost(loads, code, message), + script: sessionsLost(loads, lost), deps: { resolveLaunch: (input) => { if (!resolver.resolve) { @@ -103,7 +108,7 @@ async function openRestoreRig(code: number, message: string) { await rig.settle() await host.flushStreamedEvents(SESSION) } - return { ...opened, loads, warnings, exchange } + return { ...opened, loads, lost, warnings, exchange } } describe('a saved Grok session Grok cannot reopen', () => { @@ -211,3 +216,87 @@ describe('a created Grok session Grok reports missing on the first reopen', () = await host.close(SESSION, 'user-close') }) }) + +describe('the warning row of a replacement whose attach failed', () => { + async function replaceThenFailAttach() { + const opened = await openRestoreRig(-32603, 'session file is corrupt') + const { host, store, fence, journalDatabase, warnings } = opened + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await host.close(SESSION, 'user-close') + // The journal's open fails after the fresh session's link is durable, before its row is written. + vi.spyOn(journalDatabase, 'legacyDirectoryFor').mockImplementationOnce(() => { + throw new Error('journal path unavailable') + }) + await expect(host.attach(CALLER, attachParams(fence()))).rejects.toThrow( + 'journal path unavailable' + ) + expect( + agentSessionProviderHandleChainHead(store.getRecord(SESSION)?.providerHandleChain ?? []) + ).toMatchObject({ + handle: { nativeId: FRESH }, + replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' } + }) + expect(await warnings()).toEqual([]) + return opened + } + + it('is written by the next start that loads the fresh session, and only once', async () => { + const { host, fence, loads, warnings } = await replaceThenFailAttach() + + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + expect(await warnings()).toHaveLength(1) + + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH]) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) + + it('is written when Grok never saved the fresh session and a newer one supersedes it', async () => { + const { host, store, fence, loads, lost, warnings } = await replaceThenFailAttach() + lost.set(FRESH, { code: -32002, message: 'Resource not found' }) + + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] + expect(chain).toHaveLength(2) + expect(agentSessionProviderHandleChainHead(chain)).toMatchObject({ + handle: { nativeId: FRESH_2 }, + supersedesKey: keyOf(FRESH), + replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' } + }) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH_2]) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) +}) + +describe('a replacement Grok never saved, superseded after its row was written', () => { + it('keeps the one row: the conversation lost is still the first one', async () => { + const { host, fence, loads, lost, warnings } = await openRestoreRig( + -32603, + 'session file is corrupt' + ) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + lost.set(FRESH, { code: -32002, message: 'Resource not found' }) + + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + expect(await warnings()).toHaveLength(1) + // The newer session carries the same loss, so reopening it says nothing new. + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH_2]) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-host.test-support.ts b/src/main/acp/acp-structured-host.test-support.ts index 6d04f5eddf7e..afc1c1c42401 100644 --- a/src/main/acp/acp-structured-host.test-support.ts +++ b/src/main/acp/acp-structured-host.test-support.ts @@ -70,7 +70,8 @@ export function launch(resume: () => boolean): () => Promise false + mayBeUnsaved: () => false, + unannouncedLosses: () => [] } : null }) diff --git a/src/main/acp/acp-structured-launch-resolution.test.ts b/src/main/acp/acp-structured-launch-resolution.test.ts index d9129b2ff9a1..0e7af326a604 100644 --- a/src/main/acp/acp-structured-launch-resolution.test.ts +++ b/src/main/acp/acp-structured-launch-resolution.test.ts @@ -7,9 +7,14 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' import { createJournalReducerState } from '../native-chat/agent-session-journal/journal-reducer' -import { spellProviderTimelineKey } from '../native-chat/agent-session-timeline/provider-timeline-identity' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + createLegacyProviderTimelineIdentityScheme, + spellProviderTimelineKey +} from '../native-chat/agent-session-timeline/provider-timeline-identity' import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor' import { ACP_CHILD_ENV_TO_DELETE, acpLaunchSpecFor } from './acp-launch-specs' +import { acpSessionNotRestoredItem } from './acp-session-reopen-failure' import { createAcpStructuredLaunchResolver } from './acp-structured-launch-resolution' const GROK = acpLaunchSpecFor('grok')! @@ -165,6 +170,87 @@ describe('ACP launch resolution', () => { ).toBe(false) }) + it('names the lost conversations whose warning row no session of the chat wrote', async () => { + const link = (nativeId: string, replaced?: string) => ({ + linkId: `link-${nativeId}`, + origin: 'created' as const, + mintedAtFence: 1, + observedAt: 1, + handle: { transport: 'acp', agent: 'grok', nativeId }, + ...(replaced + ? { + replaces: { + key: agentSessionProviderHandleKey({ + transport: 'acp', + agent: 'grok', + nativeId: replaced + }), + reason: 'restore-failed', + replacedAt: 1 + } + } + : {}) + }) + const lostKey = agentSessionProviderHandleKey({ + transport: 'acp', + agent: 'grok', + nativeId: 'acp-1' + }) + const replaced = grokRecord({ providerHandleChain: [link('acp-1'), link('acp-2', 'acp-1')] }) + /** A journal holding the row for `lostKey`, written while `providerSession` ran. */ + const withRow = (providerSession: string): JournalLoad => { + const load = journalWithTurns() + const itemId = agentJournalItemKey( + createLegacyProviderTimelineIdentityScheme({ + agent: 'grok', + sessionId: identity.sessionId + }).item({ + namespace: providerSession, + family: 'item', + key: { source: 'provider', value: acpSessionNotRestoredItem(lostKey) }, + thread: providerSession + }) + ) + load.state.items.set(itemId, { + itemId, + revision: 1, + sequence: 2, + observedAt: 1, + body: { kind: 'status', tone: 'warning', text: 'forgot' } + }) + return load + } + const losses = async (record: AgentSessionRecord, readJournal: () => JournalLoad | null) => + (await resolver(record, false, readJournal).resolve({ identity })).resume?.unannouncedLosses() + + const reads: string[] = [] + // A chain that lost nothing never reads the journal. + expect( + await losses(grokRecord({ providerHandleChain: [link('acp-1')] }), () => { + reads.push('read') + return null + }) + ).toEqual([]) + expect(reads).toEqual([]) + expect(await losses(replaced, () => null)).toEqual([lostKey]) + expect(await losses(replaced, () => journalWithTurns('acp-2'))).toEqual([lostKey]) + expect(await losses(replaced, () => withRow('acp-2'))).toEqual([]) + // A row the superseded replacement wrote still counts. + expect(await losses(replaced, () => withRow('acp-gone'))).toEqual([]) + // A journal that does not read whole proves nothing, so no row is written. + expect( + await losses(replaced, () => ({ + ...journalWithTurns(), + damage: { sequence: 3, cause: 'sequence-gap' } + })) + ).toEqual([]) + expect( + await losses(replaced, () => { + throw new Error('journal_closed') + }) + ).toEqual([]) + }) + it('refuses a record pinned to another host or another agent', async () => { const remote = grokRecord() remote.location = { ...remote.location, executionHostId: 'ssh:box' } diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index 07ffc2e006d9..dbe372495a0a 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -7,17 +7,24 @@ import { delimiter } from 'node:path' import { homedir } from 'node:os' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { agentSessionProviderHandleChainHead, - agentSessionProviderHandleKey + agentSessionProviderHandleKey, + type AgentSessionProviderHandleChain } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { resolveCliCommand } from '../../shared/node-cli-command-resolution' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' -import { isProviderTimelineTurnInNamespace } from '../native-chat/agent-session-timeline/provider-timeline-identity' +import { + isProviderTimelineTurnInNamespace, + providerTimelineKeyPart, + spelledProviderTimelineItemKey +} from '../native-chat/agent-session-timeline/provider-timeline-identity' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import type { AcpLaunchSpec } from './acp-launch-specs' +import { acpSessionNotRestoredItem } from './acp-session-reopen-failure' export type AcpStructuredLaunch = { spec: AcpLaunchSpec @@ -34,6 +41,9 @@ export type AcpStructuredLaunch = { /** Only a session this chat created and never exchanged a turn on may be one the agent never * saved, and so be superseded. Read only when the agent cannot reopen it. */ mayBeUnsaved: () => boolean + /** Keys of the conversations the chain says the agent lost whose warning row the chat does not + * hold, as when the attach that would have written it failed. Read once the start succeeds. */ + unannouncedLosses: () => string[] } | null } @@ -106,7 +116,9 @@ export function createAcpStructuredLaunchResolver( key: agentSessionProviderHandleKey(head.handle), mayBeUnsaved: () => head.origin === 'created' && - nothingExchangedOn(deps.readJournal, identity.sessionId, head.handle.nativeId) + nothingExchangedOn(deps.readJournal, identity.sessionId, head.handle.nativeId), + unannouncedLosses: () => + unannouncedLosses(deps.readJournal, identity.sessionId, record.providerHandleChain) } : null } @@ -140,3 +152,41 @@ function nothingExchangedOn( } return true } + +/** Derived on every start, never stored: a row an attach failure dropped is written by the next + * start that succeeds. A journal that does not read whole proves nothing, so none is written. */ +function unannouncedLosses( + readJournal: AcpStructuredLaunchResolverDeps['readJournal'], + sessionId: string, + chain: AgentSessionProviderHandleChain +): string[] { + const lost = chain.flatMap((link) => + link.replaces?.reason === 'restore-failed' ? [link.replaces.key] : [] + ) + if (lost.length === 0) { + return [] + } + let load: JournalLoad | null + try { + load = readJournal(sessionId) + } catch { + return [] + } + if (!load) { + return lost + } + if (load.damage || load.newer) { + return [] + } + const written = new Set() + for (const itemId of load.state.items.keys()) { + const identity = parseAgentJournalItemKey(itemId) + const spelled = + identity?.provider === 'legacy' ? spelledProviderTimelineItemKey(identity.recordId) : null + if (spelled !== null) { + written.add(spelled) + } + } + // Any session's row counts: one an unused replacement wrote stays when the agent supersedes it. + return lost.filter((key) => !written.has(providerTimelineKeyPart(acpSessionNotRestoredItem(key)))) +} diff --git a/src/main/acp/acp-structured-session-adapter-resume.test.ts b/src/main/acp/acp-structured-session-adapter-resume.test.ts index 58d402896c76..1818beee8456 100644 --- a/src/main/acp/acp-structured-session-adapter-resume.test.ts +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -35,7 +35,12 @@ const hello: AgentJournalMessageItem = { const RESUMES = { agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } } } const resume = { launch: { - resume: { sessionId: PROVIDER_SESSION, key: 'acp:grok:saved', mayBeUnsaved: () => false } + resume: { + sessionId: PROVIDER_SESSION, + key: 'acp:grok:saved', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } } } @@ -280,7 +285,12 @@ describe('reattaching a Grok chat the journal holds', () => { it('starts a new session in place of a created one that session/load reports missing', async () => { const rig = await openAcpAdapterRig({ launch: { - resume: { sessionId: 'never-saved', key: 'acp:grok:never-saved', mayBeUnsaved: () => true } + resume: { + sessionId: 'never-saved', + key: 'acp:grok:never-saved', + mayBeUnsaved: () => true, + unannouncedLosses: () => [] + } }, initialize: RESUMES, script: (agent) => @@ -308,7 +318,12 @@ describe('reattaching a Grok chat the journal holds', () => { async (_label, mayBeUnsaved, code, message) => { const rig = await openAcpAdapterRig({ launch: { - resume: { sessionId: 'saved-1', key: 'acp-key-saved-1', mayBeUnsaved: () => mayBeUnsaved } + resume: { + sessionId: 'saved-1', + key: 'acp-key-saved-1', + mayBeUnsaved: () => mayBeUnsaved, + unannouncedLosses: () => [] + } }, initialize: RESUMES, script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, code, message)) @@ -333,7 +348,12 @@ describe('reattaching a Grok chat the journal holds', () => { it('refuses a signed-out agent at reopen rather than replacing its session', async () => { const rig = await openAcpAdapterRig({ launch: { - resume: { sessionId: 'saved-1', key: 'acp-key-saved-1', mayBeUnsaved: () => false } + resume: { + sessionId: 'saved-1', + key: 'acp-key-saved-1', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } }, initialize: RESUMES, script: (agent) => @@ -350,7 +370,12 @@ describe('reattaching a Grok chat the journal holds', () => { const logger = { warn: vi.fn(), error: vi.fn() } const rig = await openAcpAdapterRig({ launch: { - resume: { sessionId: 'saved-1', key: 'acp-key-saved-1', mayBeUnsaved: () => false } + resume: { + sessionId: 'saved-1', + key: 'acp-key-saved-1', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } }, initialize: RESUMES, deps: { logger }, diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 42667674dc82..3fad79da2362 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -80,7 +80,14 @@ describe('ACP structured session adapter: acquire', () => { it('replaces a created session the agent never saved with a new one', async () => { const rig = await openAcpAdapterRig({ - launch: { resume: { sessionId: 'lost', key: 'old-key', mayBeUnsaved: () => true } }, + launch: { + resume: { + sessionId: 'lost', + key: 'old-key', + mayBeUnsaved: () => true, + unannouncedLosses: () => [] + } + }, script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Session not found')) }) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts index 0fa855cc0acd..f1c0038b4d86 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts @@ -81,6 +81,17 @@ export function isProviderTimelineTurnInNamespace(turnId: string, namespace: str return turnId.startsWith(`p:${providerTimelineKeyPart(namespace)}:`) } +/** The spelled key of a provider-keyed item's record id, whichever session and thread wrote it; + * null for any other row. Compare it with `providerTimelineKeyPart(key)`. */ +export function spelledProviderTimelineItemKey(recordId: string): string | null { + if (!recordId.startsWith('item:p:')) { + return null + } + // Every part is URI-encoded, so `:` and `/` only ever separate parts. + const scoped = recordId.slice(recordId.lastIndexOf(':') + 1) + return scoped.slice(scoped.lastIndexOf('/') + 1) +} + /** The existing `legacy` arm. Turn rows keep the `turn-lifecycle:` record prefix the other lanes * write; provider keys are spelled inside their namespace, and apart from minted ones. */ export function createLegacyProviderTimelineIdentityScheme(input: { From c43cf4ccb946097f66686c17464d49ab9244a515 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:39:42 -0700 Subject: [PATCH 261/269] fix(acp): a question during a turn the agent began itself gets its cancelled reply A question or other card-opening request the agent sends while no prompt of Orca's runs (a turn it began itself, as when a background task wakes it) now gets the agent's own cancelled reply and opens no card, the same rule permissions already follow there. Nobody is waiting on that turn. A plan the agent shares in it is still shown. --- src/main/acp/acp-structured-acquire.ts | 9 +++--- src/main/acp/acp-structured-prompts.ts | 10 ++++--- .../acp-structured-request-admission.test.ts | 28 +++++++++++++++++-- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index b96296910263..cf7466f27d8f 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -97,10 +97,11 @@ export async function acquireAcpStructuredSession(input: { reattaching: false } const options = new AcpStructuredOptions() - // Orca's own prompt may ask; so may a turn the agent began itself, for a question it cannot answer - // alone. Nothing may while a Stop or steer cuts its turn short, or when no turn is open. + // Only Orca's own prompt may ask the person, as with permissions: a turn the agent began itself + // has nobody waiting on it. What a settled request carries still shows in that turn. const prompts = new AcpStructuredPrompts( () => slot.lane, + () => session?.turns.acceptsRequests === true, () => session !== null && (session.turns.acceptsRequests || @@ -129,8 +130,8 @@ export async function acquireAcpStructuredSession(input: { clientInfo: { name: 'orca', version: '1' }, onPermission: (request, context) => { if (!session?.turns.acceptsRequests) { - // No prompt of Orca's runs (or a turn the agent began itself), or a Stop or steer is - // cutting it short: nobody is there to ask. + // No prompt of Orca's runs (a turn the agent began itself included), or a Stop or steer + // is cutting it short: nobody is there to ask. return { outcome: { outcome: 'cancelled' } } } if (launch.fullAccess) { diff --git a/src/main/acp/acp-structured-prompts.ts b/src/main/acp/acp-structured-prompts.ts index dbb547b236c3..7f9516e9827a 100644 --- a/src/main/acp/acp-structured-prompts.ts +++ b/src/main/acp/acp-structured-prompts.ts @@ -35,7 +35,9 @@ export class AcpStructuredPrompts { constructor( private readonly lane: () => AcpStructuredLane | null, /** Whether a request arriving now belongs to a turn that may still ask the person. */ - private readonly admits: () => boolean + private readonly admits: () => boolean, + /** Whether its turn may still show what a request answered at once carried. */ + private readonly shows: () => boolean ) {} get size(): number { @@ -51,14 +53,14 @@ export class AcpStructuredPrompts { const translated = lane.translator.request(method, params, context.id) if (translated.settled) { // Answered at once; what it carried shows only while its turn may still show anything. - if (this.admits()) { + if (this.shows()) { lane.apply(translated.events) } return Promise.resolve(translated.settled.reply) } if (translated.presentation && !this.admits()) { - // Its turn is being stopped or steered, or none is open: the agent hears its own cancelled - // reply and no card opens. + // No prompt of Orca's runs, or a Stop or steer is cutting it short: the agent hears its own + // cancelled reply and no card opens. return Promise.resolve(translated.presentation.reply(null)) } lane.apply(translated.events) diff --git a/src/main/acp/acp-structured-request-admission.test.ts b/src/main/acp/acp-structured-request-admission.test.ts index 65ca5f29d90f..3bed54f87c9c 100644 --- a/src/main/acp/acp-structured-request-admission.test.ts +++ b/src/main/acp/acp-structured-request-admission.test.ts @@ -1,6 +1,6 @@ -// What Grok asks reaches the person only while the turn it belongs to may still ask: a Stop or a +// What Grok asks reaches the person only while Orca's prompt runs and may still ask: a Stop or a // steer withdraws what is open and refuses what arrives later with Grok's own cancelled reply, with -// no card; an answer the person already gave is still sent. +// no card, as does a turn Grok began itself; an answer the person already gave is still sent. import { afterEach, describe, expect, it } from 'vitest' import { @@ -85,6 +85,30 @@ describe('Grok requests and the turn they belong to', () => { expect(await cards()).toEqual([]) }) + it('refuses a question during a turn Grok began itself, opening no card, yet shows its plan', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + agent.notify('session/update', replyChunk('task-completed-background-1', 'Build finished; now')) + await rig.settle() + expect(await agent.request(7, 'x.ai/ask_user_question', question('call-7'))).toMatchObject({ + result: { outcome: 'cancelled' } + }) + expect( + await agent.request(8, 'x.ai/exit_plan_mode', { + sessionId: PROVIDER_SESSION, + toolCallId: 'call-8', + planContent: '# Plan' + }) + ).toMatchObject({ result: { outcome: 'abandoned' } }) + await rig.settle() + const rows = await rig.rig.rows() + expect(rows.filter((row) => row.body.kind === 'question')).toEqual([]) + expect(rows.map((row) => row.body)).toContainEqual( + expect.objectContaining({ kind: 'status', presentation: 'plan-document', text: '# Plan' }) + ) + }) + it('refuses a question when no turn is open', async () => { const rig = await openAcpAdapterRig() await rig.acquire() From 940e05a5c42fef3ec33135d264498162f0075fdb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:57:17 -0700 Subject: [PATCH 262/269] test(native-chat): import the unfinished-work capture from the module that owns it Main's reasoning sweep test (#19221) imported it from the dead-generation settlement, which this branch split it out of. --- .../structured-agent-session-reasoning-sweep.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts index c54a65a5862c..2ea74bd77499 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts @@ -13,10 +13,10 @@ import { openAgentSessionJournal } from '../agent-session-journal/journal-store- import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { - captureUnfinishedStructuredAgentSessionWork, settleStaleStructuredAgentSessionState, settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' +import { captureUnfinishedStructuredAgentSessionWork } from './structured-agent-session-unfinished-work' const SESSION = 'session-reasoning-sweep' const THREAD = 'thread-1' From 6544fc7d19ac98e52f57acf14700d9c75ed64537 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:40:05 -0700 Subject: [PATCH 263/269] refactor(native-chat): keep the session host within its line budget after main's Stop work Main's #25949 left the host at exactly its 300-line budget, and this branch's acquire-abort wiring adds one line. The reveal module now comes in as a namespace import, as the host already does for its other helper modules, and the earlier reorder of two type imports is undone. --- .../structured-agent-session-host.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 771d00fef7c0..6bd05cc3c237 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -1,5 +1,5 @@ -import type { StructuredAgentDefinition } from './structured-agent-definition' import type { AgentSessionRewindParams } from '../../../shared/agent-session-rewind' +import type { StructuredAgentDefinition } from './structured-agent-definition' import { rewindStructuredAgentSession } from './structured-agent-session-rewind' import { StructuredConversationCommandController } from './structured-conversation-command-controller' // Structured agent-session host: where the lease, journal, and provider adapter meet. @@ -15,10 +15,7 @@ import { createRestartReconciler } from './structured-agent-session-restart-reco import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' -import { - createStructuredAgentSessionHostRestore, - revealStructuredAgentSession -} from './structured-agent-session-reveal' +import * as reveal from './structured-agent-session-reveal' import { structuredAgentSessionOwnerStatus } from './structured-agent-session-owner-status' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' @@ -89,7 +86,7 @@ export class StructuredAgentSessionHost { private readonly tasks = new StructuredAgentSessionTaskQueue() private readonly runtimeState: StructuredAgentSessionHostRuntimeState private readonly reconcileLeases: ReturnType - private readonly restore: ReturnType + private readonly restore: ReturnType private readonly lifetime: StructuredAgentSessionConversationLifetime private readonly conversationDelivery: ReturnType< typeof createStructuredAgentSessionConversationDelivery @@ -129,7 +126,7 @@ export class StructuredAgentSessionHost { agentStart.ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), clientDelivery: this.clientDelivery }) - this.restore = createStructuredAgentSessionHostRestore(deps, { + this.restore = reveal.createStructuredAgentSessionHostRestore(deps, { reconcileLeases: this.reconcileLeases, resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), @@ -247,7 +244,9 @@ export class StructuredAgentSessionHost { /** Make one persisted session addressable again; see `structured-agent-session-reveal`. */ revealSession = (sessionId: string): Promise => - revealStructuredAgentSession(this.deps, sessionId, (id) => this.lifetime.conversation(id)) + reveal.revealStructuredAgentSession(this.deps, sessionId, (id) => + this.lifetime.conversation(id) + ) private serialize = this.tasks.serialize.bind(this.tasks) From 7a712ac2a4c681d5caf4d1a00b6a3c117d01df96 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:52:32 -0700 Subject: [PATCH 264/269] test(codex): move the stdout-before-exit test into its own file Main's connection test file is at its 800-line test budget, and this branch's exit-order test pushed it over (CI lint, max-lines). --- ...x-app-server-connection-exit-order.test.ts | 47 +++++++++++++++++++ .../codex/codex-app-server-connection.test.ts | 23 --------- 2 files changed, 47 insertions(+), 23 deletions(-) create mode 100644 src/main/codex/codex-app-server-connection-exit-order.test.ts diff --git a/src/main/codex/codex-app-server-connection-exit-order.test.ts b/src/main/codex/codex-app-server-connection-exit-order.test.ts new file mode 100644 index 000000000000..661d099df73c --- /dev/null +++ b/src/main/codex/codex-app-server-connection-exit-order.test.ts @@ -0,0 +1,47 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../shared/child-process/run-process' +import { openCodexAppServerConnection } from './codex-app-server-connection' + +function stubChild() { + const child = Object.assign(new EventEmitter(), { + // Outside any real process table so teardown never mistakes an unrelated process for it. + pid: 9_999_999, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(() => true) + }) + child.stdin.once('data', () => { + child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`) + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The connection reads only events, pid, streams and kill from this stub. + const spawnImpl = (() => child) as unknown as typeof spawnProcess + return { child, spawnImpl } +} + +describe('Codex app-server exit order', () => { + // Stdout can end before the exit is seen, as when the provider supervisor's own exit closes it. + it('reports an exit whose stdout ended first once the exit is seen, with its usual reason', async () => { + const { child, spawnImpl } = stubChild() + const exits: string[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onExit: (error) => exits.push(error.message) }, + spawnImpl + ) + + child.stderr.write('codex crashed\n') + child.stdout.end() + // Stream writes land a tick later, so the stderr tail is only complete here. + await new Promise((resolve) => setImmediate(resolve)) + expect(exits).toEqual([]) + child.emit('exit', 1, null) + child.emit('close', 1, null) + + expect(exits).toHaveLength(1) + expect(exits[0]).toContain('codex crashed') + await connection.close() + }) +}) diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index b568278db653..71be8472f606 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -806,29 +806,6 @@ describe('openCodexAppServerConnection', () => { await connection.close() }) - // Stdout can end before the exit is seen, as when the provider supervisor's own exit closes it. - it('reports an exit whose stdout ended first once the exit is seen, with its usual reason', async () => { - const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) - answerInitialize(child) - const exits: string[] = [] - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - { onExit: (error) => exits.push(error.message) }, - spawnImpl - ) - - child.stderr.write('codex crashed\n') - child.stdout.end() - await flushStreams() - expect(exits).toEqual([]) - child.emit('exit', 1, null) - child.emit('close', 1, null) - - expect(exits).toHaveLength(1) - expect(exits[0]).toContain('codex crashed') - await connection.close() - }) - it('reports one exit for a death that arrives through two listeners', async () => { const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) answerInitialize(child) From eaa1a983ed03fcbce5c325fa24e119e216b54146 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:43:22 -0700 Subject: [PATCH 265/269] fix(acp): cancel a running turn before a close, dispose or quit ends the agent Closing a tab, disposing a session or quitting while an ACP agent's turn ran killed the process without asking the agent to cancel first. A requested close now does what a Stop does: withdraw the agent's open requests and held steers, send session/cancel once, and wait for the turn to end, bounded by the Stop's grace (4 s), before closing the process. A close that follows a Stop sends no second cancel and waits only what is left of that Stop's grace. An idle close, a lost connection and a sink-failure force close are unchanged. The cancel-and-wait moves to acp-structured-stop.ts, shared by Stop and close. --- ...tructured-session-adapter-teardown.test.ts | 162 ++++++++++++++++++ .../acp/acp-structured-session-adapter.ts | 48 ++---- src/main/acp/acp-structured-stop.ts | 62 +++++++ src/main/acp/acp-structured-turns.ts | 20 ++- 4 files changed, 257 insertions(+), 35 deletions(-) create mode 100644 src/main/acp/acp-structured-session-adapter-teardown.test.ts create mode 100644 src/main/acp/acp-structured-stop.ts diff --git a/src/main/acp/acp-structured-session-adapter-teardown.test.ts b/src/main/acp/acp-structured-session-adapter-teardown.test.ts new file mode 100644 index 000000000000..9d9e3022826e --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-teardown.test.ts @@ -0,0 +1,162 @@ +// A close, dispose or quit while a turn runs: the agent is asked to cancel it and given the Stop's +// grace to end it before its process goes, as a Stop does; an idle close goes at once. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { HOST_TEST_SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { tick, type FakeFrame } from './acp-scripted-agent.test-support' +import { + openAcpAdapterRig, + sendHello, + type AcpAdapterRig, + type FakeAcpChild +} from './acp-structured-adapter.test-support' +import { framesOf, openAttachedHostRig, send } from './acp-structured-host.test-support' +import { ACP_STOP_GRACE_MS } from './acp-structured-session-adapter-deps' + +afterEach(async () => { + vi.useRealTimers() + await closeProviderTimelineRigs() +}) + +/** What Orca had written, and whether the turn had ended, when it first asked the child to close. */ +function watchClose(child: FakeAcpChild, turn: { ended: boolean }) { + const atClose: { cancels: number | null; turnEnded: boolean | null } = { + cancels: null, + turnEnded: null + } + const close = child.close.bind(child) + child.close = async (error?: Error) => { + atClose.cancels ??= framesOf(child, 'session/cancel').length + atClose.turnEnded ??= turn.ended + return close(error) + } + return atClose +} + +/** The agent ends the cancelled turn a moment after Orca asks. */ +function answerCancel(child: FakeAcpChild, prompt: FakeFrame, turn: { ended: boolean }) { + child.agent.on('session/cancel', () => + setTimeout(() => { + turn.ended = true + child.agent.reply(prompt, { stopReason: 'cancelled' }) + }, 30) + ) +} + +async function runningPrompt(rig: AcpAdapterRig): Promise { + await rig.acquire() + await sendHello(rig, 'send-1') + return rig.frame('session/prompt') +} + +describe('ACP teardown of a running turn', () => { + it('a close cancels the turn first and ends the child once the agent ends it', async () => { + const rig = await openAcpAdapterRig() + const prompt = await runningPrompt(rig) + const turn = { ended: false } + answerCancel(rig.child(), prompt, turn) + const atClose = watchClose(rig.child(), turn) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(atClose).toEqual({ cancels: 1, turnEnded: true }) + expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + }) + + it('a dispose ends the child at the grace when the agent never ends its turn', async () => { + const rig = await openAcpAdapterRig() + await runningPrompt(rig) + const child = rig.child() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const closing = rig.adapter.disposeSession(SESSION) + await vi.advanceTimersByTimeAsync(ACP_STOP_GRACE_MS - 1) + await tick() + expect(framesOf(child, 'session/cancel')).toHaveLength(1) + expect(child.closes).toBe(0) + await vi.advanceTimersByTimeAsync(1) + await expect(closing).resolves.toBe(true) + expect(child.closes).toBe(1) + }) + + it('a quit cancels every running turn before it ends the children, within the grace', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 20 } }) + await runningPrompt(rig) + const atClose = watchClose(rig.child(), { ended: false }) + await rig.adapter.closeAll() + expect(atClose).toEqual({ cancels: 1, turnEnded: false }) + expect(rig.child().closes).toBe(1) + }) + + it('a close still ends the child when the cancel cannot be written', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 20 } }) + await runningPrompt(rig) + const cancel = vi.spyOn(rig.child(), 'cancel').mockRejectedValue(new Error('EPIPE')) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(cancel).toHaveBeenCalledTimes(1) + expect(rig.child().closes).toBe(1) + }) + + it('a close after a Stop asks no second cancel and waits only what is left of its grace', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 60_000 } }) + await runningPrompt(rig) + const child = rig.child() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + await vi.advanceTimersByTimeAsync(20_000) + const closing = rig.adapter.closeSession(SESSION) + await vi.advanceTimersByTimeAsync(40_000 - 1) + expect(child.closes).toBe(0) + await vi.advanceTimersByTimeAsync(1) + await expect(closing).resolves.toBe(true) + expect(framesOf(child, 'session/cancel')).toHaveLength(1) + }) +}) + +describe('ACP teardown with nothing to cancel', () => { + it('an idle close writes no cancel and ends the child at once', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 60_000 } }) + await rig.acquire() + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(0) + expect(rig.child().closes).toBe(1) + }) + + it('a close after the connection broke writes no cancel and waits for nothing', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 60_000 } }) + await runningPrompt(rig) + const child = rig.child() + const cancel = vi.spyOn(child, 'cancel') + let proves = false + child.proveClose = async () => { + if (proves) { + child.exit() + } + return proves + } + child.agent.stdin.destroy() + await vi.waitFor(() => expect(child.closes).toBe(1)) + proves = true + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(cancel).not.toHaveBeenCalled() + }) +}) + +describe('a Grok chat closed through the host while its turn runs', () => { + it('cancels the turn before ending the process, and the turn ends interrupted', async () => { + const { rig, host, turns } = await openAttachedHostRig() + const child = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + const turn = { ended: false } + answerCancel(child, prompt, turn) + const atClose = watchClose(child, turn) + await host.close(HOST_TEST_SESSION, 'user-close') + expect(atClose).toEqual({ cancels: 1, turnEnded: true }) + expect(child.exited).toBe(true) + expect((await turns()).at(-1)).toMatchObject({ state: 'interrupted' }) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 722f694554d7..b0dd9804b5ea 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -31,6 +31,7 @@ import { AcpStructuredStarts, type AcpStartAttempt } from './acp-structured-star import { waitForAcpExit } from './acp-structured-connection' import { AcpConnectionClosedError } from './acp-errors' import { acpPromptBlocks } from './acp-structured-turns' +import { awaitAcpTurnEnd, interruptAcpTurn, windDownAcpTurn } from './acp-structured-stop' import { ACP_OPTION_WRITE_TIMEOUT_MS, ACP_STOP_GRACE_MS, @@ -99,7 +100,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte if (connection && error instanceof AcpConnectionClosedError) { // A protocol that broke before the exit was seen: wait (bounded) for that exit, so the // failure carries the agent's last words, as a running session's end does. - await waitForAcpExit(connection, this.deps.stopGraceMs ?? ACP_STOP_GRACE_MS, attempt.signal) + await waitForAcpExit(connection, this.stopGraceMs(), attempt.signal) } // Checked before the close below, which would make any exit look like one Orca asked for. const exitedOnItsOwn = connection?.exited === true && !attempt.signal.aborted @@ -167,15 +168,9 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte if (!session.turns.running && session.lane.openTurnId === null && !session.turns.holdsSteers) { return { cancelled: false, refusal: { turnNotRunning: true } } } - session.turns.stop() - if (!session.turns.running && session.lane.openTurnId === null) { - return { cancelled: true } - } - // The agent hears its own cancelled reply to what it asked, then may end its turn its own way; - // the host ends the process once that lands or the grace runs out (`awaitStoppedRequestEnd`), - // never waiting on this write. - session.prompts.withdrawAll() - void session.connection.cancel().catch(() => undefined) + // The agent may end its turn its own way; the host ends the process once that lands or the + // grace runs out (`awaitStoppedRequestEnd`), never waiting on the cancel's write. + interruptAcpTurn(session) return { cancelled: true } } @@ -186,25 +181,8 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte awaitStoppedRequestEnd = async (sessionId: string, stoppedAt: number): Promise => { const session = this.sessions.get(sessionId) - if (!session) { - return - } - const grace = this.deps.stopGraceMs ?? ACP_STOP_GRACE_MS - let timer: ReturnType | undefined - const elapsed = new Promise((resolve) => { - timer = setTimeout(resolve, Math.max(0, stoppedAt + grace - Date.now())) - }) - const turnId = session.lane.openTurnId - try { - await Promise.race([ - Promise.all([ - session.turns.whenIdle(), - turnId === null ? undefined : session.lane.whenTurnLeaves(turnId) - ]), - elapsed - ]) - } finally { - clearTimeout(timer) + if (session) { + await awaitAcpTurnEnd(session, stoppedAt, this.stopGraceMs()) } } @@ -287,6 +265,14 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte if (!session || session.ended) { return true } + if (requested && session.journalClosed === null) { + // A close, dispose or quit ends a running turn as a Stop does before the child goes. + session.closeRequested = true + await windDownAcpTurn(session, this.stopGraceMs()) + if (session.ended) { + return true + } + } // A connection loss already decided why the child ends; a later stop does not relabel it. if (session.journalClosed === null) { session.closeRequested ||= requested @@ -345,6 +331,10 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return session } + private stopGraceMs(): number { + return this.deps.stopGraceMs ?? ACP_STOP_GRACE_MS + } + private now(): number { return (this.deps.now ?? Date.now)() } diff --git a/src/main/acp/acp-structured-stop.ts b/src/main/acp/acp-structured-stop.ts new file mode 100644 index 000000000000..85e48625b92b --- /dev/null +++ b/src/main/acp/acp-structured-stop.ts @@ -0,0 +1,62 @@ +// How a running ACP turn ends before its child does, for a Stop and a requested close alike: the +// cancel goes out once, and the agent gets a bounded grace, counted from it, to end its turn. + +import type { AcpStructuredSession } from './acp-structured-session' + +type AcpStoppableSession = Pick + +/** A turn a cancel can end: Orca's prompt, or one the agent opened itself. */ +function acpTurnInFlight(session: AcpStoppableSession): boolean { + return session.turns.running || session.lane.openTurnId !== null +} + +/** As a Stop: held steers and the agent's open requests are withdrawn, then the cancel is written, + * never awaited; a failed write leaves the grace to run out. */ +export function interruptAcpTurn(session: AcpStoppableSession): void { + session.turns.stop(Date.now()) + if (!acpTurnInFlight(session)) { + return + } + session.prompts.withdrawAll() + void session.connection.cancel().catch(() => undefined) +} + +/** Resolves once the running turn ended, or `graceMs` after `stoppedAt` (`Date.now()` time). */ +export async function awaitAcpTurnEnd( + session: AcpStoppableSession, + stoppedAt: number, + graceMs: number +): Promise { + let timer: ReturnType | undefined + const elapsed = new Promise((resolve) => { + timer = setTimeout(resolve, Math.max(0, stoppedAt + graceMs - Date.now())) + }) + const turnId = session.lane.openTurnId + try { + await Promise.race([ + Promise.all([ + session.turns.whenIdle(), + turnId === null ? undefined : session.lane.whenTurnLeaves(turnId) + ]), + elapsed + ]) + } finally { + clearTimeout(timer) + } +} + +/** Before a requested close: a running turn is cancelled, unless a Stop already did, and waited on + * within the grace that cancel started. */ +export async function windDownAcpTurn( + session: AcpStoppableSession, + graceMs: number +): Promise { + const { connection, turns } = session + if (connection.exited || connection.closed || !acpTurnInFlight(session)) { + return + } + if (turns.stoppedAt === null) { + interruptAcpTurn(session) + } + await awaitAcpTurnEnd(session, turns.stoppedAt ?? Date.now(), graceMs) +} diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index 2f2b683c9a25..df5495f65edc 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -66,8 +66,8 @@ export class AcpStructuredTurns { private readonly unsettled = new Set() private readonly idleWaiters = new Set<() => void>() private ended = false - /** A Stop reached this session; it ends with the process. */ - private stopping = false + /** When a Stop or a requested close reached this session; it ends with the process. */ + private stopAt: number | null = null constructor(private readonly deps: AcpStructuredTurnsDeps) {} @@ -79,7 +79,10 @@ export class AcpStructuredTurns { * short (no steer's cancel, no Stop). */ get acceptsRequests(): boolean { return ( - !this.stopping && this.active !== null && this.steerCancelled !== this.active && !this.ended + this.stopAt === null && + this.active !== null && + this.steerCancelled !== this.active && + !this.ended ) } @@ -90,12 +93,17 @@ export class AcpStructuredTurns { /** Whether a Stop reached this session. */ get stopped(): boolean { - return this.stopping + return this.stopAt !== null + } + + /** When the first Stop reached this session (`Date.now()` time); null before one did. */ + get stoppedAt(): number | null { + return this.stopAt } /** A Stop: held steers never reach the agent, and nothing it asks from now on is shown. */ - stop(): boolean { - this.stopping = true + stop(at: number): boolean { + this.stopAt ??= at return this.withdrawSteers() } From 48567d2040950be57ae1f0c18a15169607c4c183 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:59:32 -0700 Subject: [PATCH 266/269] test(codex): pass resolveLaunchArgs in the stopped-send-order test so typecheck passes Main's typecheck fails here too: #25721 made resolveLaunchArgs required and this test (#25051) predates it. Same line, same place as the open main fix (#25977), so merging main after it lands is a no-op. --- .../structured-agent-session-codex-stopped-send-order.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts index 80432e8399c8..9f43ee59cf95 100644 --- a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts @@ -176,6 +176,7 @@ async function startHost(): Promise { resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), + resolveLaunchArgs: () => [], openCodexConnection: openConnection, readProcessStartTime: async () => 1_700_000_000_000 }) From 554d1a5fe49bf764ae4b6b72b98cbfa51d98df07 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:47:27 -0700 Subject: [PATCH 267/269] test(native-chat): run the close-aborts-start test on the Node runtime, as its SQLite journal fixture requires --- config/scripts/vitest-sqlite-runtime-files.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 109048a5e3b8..4ff4049f9bc7 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -53,6 +53,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts', From 27c63719b2a3757fc234384aada5fcb562ec8196 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:02:22 -0700 Subject: [PATCH 268/269] refactor(native-chat): the Stop control reads the outbox itself, so the chat hook stays within its line limit --- .../structured-agent-session-stop-control.ts | 20 +++++++++++++------ .../use-structured-agent-session.ts | 4 +--- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts b/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts index c28c8e770981..63ecacd33bdb 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts @@ -1,4 +1,7 @@ // Whether a structured chat offers Stop, and what Stop does, from what this view knows of the chat. +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { hasUnsentStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal' export function structuredAgentSessionStopControl(input: { /** The host has published this chat to this view. */ @@ -9,13 +12,18 @@ export function structuredAgentSessionStopControl(input: { stopsConversation: boolean stop: (turnId: string | null, withdrawUnsent: () => void) => Promise } - transportState: { turnId: string | null; isWorking: boolean } - /** This client still holds a send the host has not taken. */ - holdsUnsent: boolean - withdrawUnsent: () => void + transportState: { + turnId: string | null + isWorking: boolean + submissions: readonly AgentJournalSubmission[] + } + /** This client's sends the host may not have taken yet. */ + outbox: { outbox: readonly StructuredAgentSessionOutboxEntry[]; withdrawUnsent: () => void } }): { canStop: boolean; stop: () => Promise } { - const { published, host, holdsUnsent, withdrawUnsent } = input - const { turnId, isWorking } = input.transportState + const { published, host } = input + const { turnId, isWorking, submissions } = input.transportState + const { withdrawUnsent } = input.outbox + const holdsUnsent = hasUnsentStructuredAgentSessionOutboxEntry(input.outbox.outbox, submissions) return { // Before the host publishes this chat to this view, nothing sent has reached it: a Stop takes // back what this client holds, so a start that never answers cannot hold the message hostage. diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index af3e32fc30bf..bfa06e206454 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -14,7 +14,6 @@ import { supportsStructuredAgentSessionQuestionAnswers } from '@/runtime/structured-agent-session-client' import { useStructuredAgentSessionHostQueuesMessagesState } from '@/runtime/structured-agent-session-host-capability' -import { hasUnsentStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal' import { structuredAgentSessionStopControl } from './structured-agent-session-stop-control' import { legacyAgentSessionSelectedOptionId, @@ -272,8 +271,7 @@ export function useStructuredAgentSession(args: { published: transportEnabled, host: stopControl, transportState, - holdsUnsent: hasUnsentStructuredAgentSessionOutboxEntry(outbox, transportState.submissions), - withdrawUnsent: outboxController.withdrawUnsent + outbox: outboxController }), stopPressed: stopControl.pressed, queuedMessages: queuedController, From 1d98732e68223fde80506e85b9bc4f0331c51d88 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:07:35 -0700 Subject: [PATCH 269/269] Keep the session host under the line cap after main's two new delegates Pass the lifetime's conversation opener to reveal directly; it is already passed unbound to the mutation context. --- .../agent-session-wire/structured-agent-session-host.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 2d6786918f65..9e173cf78c49 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -243,9 +243,7 @@ export class StructuredAgentSessionHost { /** Make one persisted session addressable again; see `structured-agent-session-reveal`. */ revealSession = (sessionId: string): Promise => - reveal.revealStructuredAgentSession(this.deps, sessionId, (id) => - this.lifetime.conversation(id) - ) + reveal.revealStructuredAgentSession(this.deps, sessionId, this.lifetime.conversation) private serialize = this.tasks.serialize.bind(this.tasks)