diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 7bf70ecda3d2..a1cad1a8e123 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -2,6 +2,8 @@ import { SQLITE_DATABASE_RUNTIME_INCLUDE } from './vitest-sqlite-database-runtim // Real SQLite fixtures keep Node publication and close semantics. export const SQLITE_RUNTIME_INCLUDE = [ + 'src/main/acp/acp-structured-publish-at-spawn.test.ts', + 'tests/e2e/native-chat-startup-option-pickers.unit.test.ts', ...SQLITE_DATABASE_RUNTIME_INCLUDE, 'src/main/acp/acp-structured-host-live-listing.test.ts', 'src/main/acp/acp-structured-host-configured-default.test.ts', @@ -22,6 +24,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/acp/acp-structured-child-work.test.ts', 'src/main/acp/acp-structured-host-subagent-stop.test.ts', 'src/main/acp/acp-timeline-grok-subagent-retention.test.ts', + 'src/main/acp/acp-scripted-child-runtime.test.ts', 'src/main/active-view-persistence-boundary.test.ts', 'src/main/automations/automation-dispatch-host-fence.test.ts', 'src/main/automations/automation-owner-fencing.test.ts', @@ -128,6 +131,8 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts', @@ -169,6 +174,8 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts', @@ -246,6 +253,8 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts', 'src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts', 'src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts', + 'src/main/runtime/structured-agent-session-codex-scripted-child.test.ts', + 'src/main/runtime/structured-agent-session-codex-start-deadline.test.ts', 'src/main/runtime/structured-agent-session-codex-settlement-order.test.ts', 'src/main/runtime/structured-agent-session-integration-replay.test.ts', 'src/main/runtime/structured-agent-session-integration.test.ts', diff --git a/src/main/acp/acp-prompt-content.ts b/src/main/acp/acp-prompt-content.ts index 1a461602587e..a30aadb27878 100644 --- a/src/main/acp/acp-prompt-content.ts +++ b/src/main/acp/acp-prompt-content.ts @@ -21,7 +21,7 @@ import { } from '../../shared/node-bounded-file-reader' import type { AcpLaunchSpec } from './acp-launch-specs' import { resolveAcpPeerOptions } from './acp-peer-limits' -import { acpAgentName } from './acp-structured-acquire' +import { acpAgentName } from './acp-structured-agent-definitions' import type { AcpStructuredConnection } from './acp-structured-connection' import { acpSessionAcceptsImages } from './acp-structured-options' import type { ContentBlock } from './generated/acp-protocol.generated' diff --git a/src/main/acp/acp-scripted-child-runtime.test.ts b/src/main/acp/acp-scripted-child-runtime.test.ts new file mode 100644 index 000000000000..7f593c2613e2 --- /dev/null +++ b/src/main/acp/acp-scripted-child-runtime.test.ts @@ -0,0 +1,88 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + HOST_TEST_SESSION, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from '../runtime/structured-agent-session-runtime' +import { acpScriptedChild } from './acp-scripted-child.test-fixture' + +let stateDirectory: string | undefined +let operation = 0 +const operationId = () => `${Date.now()}-${(++operation).toString(16).padStart(32, '0')}` +const caller = { callerKey: 'scripted-acp-runtime' } + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (stateDirectory) { + await rm(stateDirectory, { recursive: true, force: true }) + } + stateDirectory = undefined +}) + +describe.each(['grok', 'opencode'] as const)('%s runtime with a scripted ACP child', (agent) => { + it('attaches and accepts a send while startup is held, then delivers once the protocol session opens', async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-scripted-acp-')) + const script = acpScriptedChild(agent)() + const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => stateDirectory ?? '', + resolveLaunchArgs: () => [], + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), + ...script.deps + }) + const attach = hostTestAttachParams(null, { + provider: agent, + agent, + providerHandle: undefined, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-1', + workspaceKind: 'folder' + }, + accountHome: + agent === 'grok' + ? { variable: 'GROK_HOME', path: stateDirectory } + : { kind: 'opencode', locator: { kind: 'unmanaged' } } + }) + attach.envelope.clientOperationId = operationId() + const attached = await host.attach(caller, attach) + expect(attached).toMatchObject({ ok: true }) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + const body = hostTestMessage('message during startup') + const sent = await host.send(caller, { + body, + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: attached.value.fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: HOST_TEST_SESSION, + fields: { body } + }) + } + }) + expect(sent).toMatchObject({ ok: true }) + expect(script.prompts()).toEqual([]) + await vi.waitFor(() => expect(script.spawns()).toBe(1)) + script.releaseHandshake() + await vi.waitFor(() => expect(script.prompts()).toEqual(['message during startup'])) + await host.close(HOST_TEST_SESSION, 'user-close') + expect(script.closes()).toBe(1) + }) +}) diff --git a/src/main/acp/acp-scripted-child.test-fixture.ts b/src/main/acp/acp-scripted-child.test-fixture.ts new file mode 100644 index 000000000000..c81d0ce4709e --- /dev/null +++ b/src/main/acp/acp-scripted-child.test-fixture.ts @@ -0,0 +1,151 @@ +import { z } from 'zod' +import type { + ScriptedAgentChild, + ScriptedAgentChildFactory +} from '../runtime/structured-agent-scripted-child.test-fixture' +import { FakeAcpChild, GROK_CONFIG_OPTIONS } from './acp-structured-adapter.test-support' +import type { FakeFrame } from './acp-scripted-agent.test-support' +import type { AcpLaunchSpec } from './acp-launch-specs' + +const promptParams = z.object({ + sessionId: z.string(), + prompt: z.array(z.object({ type: z.string(), text: z.string().optional() })), + _meta: z.object({ promptId: z.string() }).optional() +}) +const loadParams = z.object({ sessionId: z.string() }) +const configParams = z.object({ configId: z.string(), value: z.string() }) + +export function acpScriptedChild(agent: AcpLaunchSpec['agent']): ScriptedAgentChildFactory { + return () => { + let current: FakeAcpChild | undefined + let handshake: (() => void) | undefined + let released = false + let opened = 0 + let reopened = 0 + let closed = 0 + const texts: string[] = [] + const fixture: ScriptedAgentChild = { + holdHandshakes: true, + completeTurns: true, + deps: { + resolveEnvironment: async () => ({ PATH: '' }), + acpLaunchCommand: { + resolveCommand: () => 'scripted-acp-agent', + probeVersion: async () => true + }, + readProcessStartTime: async () => 1_700_000_000_000 + opened, + openAcpConnection: (launch, options) => { + opened += 1 + handshake = undefined + released = false + const child = new FakeAcpChild(launch, options) + current = child + let sessionId = `${agent}-scripted-session` + let running: FakeFrame | undefined + let configOptions = GROK_CONFIG_OPTIONS.map((option) => ({ ...option })) + const script = child.agent + script.on('initialize', (frame) => + script.reply(frame, { + protocolVersion: 1, + agentCapabilities: { loadSession: true } + }) + ) + const start = (frame: FakeFrame, resumes: boolean): void => { + if (resumes) { + reopened += 1 + sessionId = loadParams.parse(frame.params).sessionId + } + const answer = () => { + if (child.closed || child.exited) { + return + } + script.reply(frame, { ...(!resumes ? { sessionId } : {}), configOptions }) + } + if (fixture.holdHandshakes && !released) { + handshake = answer + } else { + answer() + } + } + script.on('session/new', (frame) => start(frame, false)) + script.on('session/load', (frame) => start(frame, true)) + script.on('session/set_config_option', (frame) => { + const { configId, value } = configParams.parse(frame.params) + configOptions = configOptions.map((option) => + option.id === configId ? { ...option, currentValue: value } : option + ) + script.reply(frame, { configOptions }) + }) + script.on('session/set_model', (frame) => script.reply(frame, {})) + // Grok's subagent-stop probe: the handler refuses the missing id, so stops are offered. + script.on('_x.ai/subagent/cancel', (frame) => + script.fail( + frame, + -32602, + 'Invalid params', + 'invalid params: missing field `subagentId`' + ) + ) + // Its background-task stop probe, answered the same way. + script.on('_x.ai/task/kill', (frame) => + script.fail( + frame, + -32602, + 'Invalid params', + 'invalid params: missing field `sessionId`' + ) + ) + script.on('session/prompt', (frame) => { + const params = promptParams.parse(frame.params) + texts.push(params.prompt.map((block) => block.text ?? '').join('')) + running = frame + script.notify('session/update', { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Scripted response' } + }, + ...(params._meta ? { _meta: params._meta } : {}) + }) + if (fixture.completeTurns) { + script.reply(frame, { stopReason: 'end_turn' }) + running = undefined + } + }) + script.on('session/cancel', () => { + if (running) { + script.reply(running, { stopReason: 'cancelled' }) + } + running = undefined + }) + const prove = child.proveClose + child.proveClose = async () => { + if (!child.exited) { + closed += 1 + } + return prove() + } + return child + } + }, + releaseHandshake: () => { + released = true + handshake?.() + handshake = undefined + }, + failHandshake: (message) => { + if (!current) { + throw new Error('no scripted ACP child') + } + current.stderr = message + current.exit() + handshake = undefined + }, + prompts: () => texts, + spawns: () => opened, + resumes: () => reopened, + closes: () => closed + } + return fixture + } +} diff --git a/src/main/acp/acp-scripted-connection.test-fixture.ts b/src/main/acp/acp-scripted-connection.test-fixture.ts new file mode 100644 index 000000000000..a5d2dc29953f --- /dev/null +++ b/src/main/acp/acp-scripted-connection.test-fixture.ts @@ -0,0 +1,93 @@ +import type { ProviderProcessExit } from '../provider-process/managed-provider-process' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +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 { AcpScriptedAgent } from './acp-scripted-agent.test-support' +export const PID = 4242 + +/** 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 = '' + processTreeUnproven = false + private exitListeners: ((exit: ProviderProcessExit) => void)[] = [] + private gone = false + private closing = false + private lostWith: Error | undefined + closes = 0 + + 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: (exit: ProviderProcessExit) => void): void { + if (this.gone) { + listener(FAKE_EXIT) + } else { + this.exitListeners.push(listener) + } + } + stderrTail(): string { + return this.stderr + } + 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 + } + override close(error?: Error): Promise { + this.closes += 1 + this.closing ||= !this.gone + this.drainNotifications(error) + return this.proveClose().finally(() => super.close(error)) + } + /** The agent process ends on its own (or Orca's close landed). */ + exit(): void { + if (this.gone) { + return + } + this.gone = true + 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 } diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 926a83623c92..071458ba0a89 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -1,13 +1,11 @@ // 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 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. +// process) and record it before any handshake, so the host publishes the child at spawn. The +// handshake (`acp-structured-handshake`) runs after: initialize, then reattach the session this chat +// proved with `session/load` (`session/resume` for an agent that cannot load) or start a new one, +// and the host receives protocol facts once that session answers. The handshake has no time bound of +// its own: the host's startup limit, or a close, Stop or quit, stops it at any point. -import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { initializeAcpStructuredSession } from './acp-structured-handshake' import { AgentSessionPreSpawnError, type AgentSessionAcquisition, @@ -15,34 +13,27 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' import { providerSpawnedProcessIdentity } from '../provider-process/provider-spawned-process-identity' -import { AcpAgentError } from './acp-errors' -import { acpAuthenticationRequired, acpSignInRequiredRefusal } from './acp-turn-failures' import { - ACP_REOPEN_FAILED, - acpReopenTakeover, - acpSessionNotRestoredRow -} from './acp-session-reopen-failure' + assertAcpStartingSession, + isAcpReadySession, + type AcpStructuredChild +} from './acp-structured-child' import type { AcpSessionEvent } from './acp-session-runtime' import type { AcpStructuredConnection } from './acp-structured-connection' -import { ACP_HANDLE_TRANSPORT, acpAgentName } from './acp-structured-agent-definitions' +import { acpAgentName } from './acp-structured-agent-definitions' import { AcpStructuredLane, acpLaneChildWorkDelivery } from './acp-structured-lane' -import { NO_ACP_CHILD_STOPS, probeAcpChildStops } from './acp-structured-child-stop' +import { NO_ACP_CHILD_STOPS } from './acp-structured-child-stop' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' -import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options' +import { AcpStructuredOptions } from './acp-structured-options' import { AcpStructuredPrompts } from './acp-structured-prompts' -import { - asReattachHistory, - routeAcpSessionEvent, - type AcpStructuredSession -} from './acp-structured-session' +import { asReattachHistory, routeAcpSessionEvent } from './acp-structured-session' import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' -import { AcpStructuredTurns, type AcpStructuredTurnsDeps } from './acp-structured-turns' +import type { AcpStructuredTurnsDeps } from './acp-structured-turns' import { RequestPermissionResponseSchema } from './generated/acp-protocol.generated' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' /** Frames an agent may send before its session exists; past this they are dropped. */ const MAX_EARLY_FRAMES = 2_048 -export { acpAgentName } from './acp-structured-agent-definitions' export async function acquireAcpStructuredSession(input: { acquire: StructuredAgentSessionAcquireInput @@ -53,13 +44,16 @@ export async function acquireAcpStructuredSession(input: { /** 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 + onExit: (session: AcpStructuredChild | null) => void /** The protocol broke with the process perhaps still running. Null while starting: then the * start itself fails. */ - onConnectionLost: (session: AcpStructuredSession | null, error: Error) => void + onConnectionLost: (session: AcpStructuredChild | null, error: Error) => void onSettled: AcpStructuredTurnsDeps['settle'] forceClose: (sessionId: string) => void -}): Promise<{ acquisition: AgentSessionAcquisition; session: AcpStructuredSession }> { +}): Promise<{ + acquisition: AgentSessionAcquisition + session: AcpStructuredChild +}> { const { acquire, deps, generation } = input const { spec } = deps const sessionId = acquire.identity.sessionId @@ -83,7 +77,7 @@ export async function acquireAcpStructuredSession(input: { throw new AgentSessionPreSpawnError(error) }) closedBeforeSpawn() - let session: AcpStructuredSession | null = null + let session: AcpStructuredChild | 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 } = { lane: null, @@ -94,9 +88,9 @@ export async function acquireAcpStructuredSession(input: { // 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, + () => isAcpReadySession(session) && session.turns?.acceptsRequests === true, () => - session !== null && + isAcpReadySession(session) && (session.turns.acceptsRequests || (!session.turns.running && !session.turns.stopped && session.lane.openTurnId !== null)) ) @@ -120,7 +114,7 @@ export async function acquireAcpStructuredSession(input: { clientInfo: { name: 'orca', version: '1' }, ...(acquire.onOutput ? { onOutput: acquire.onOutput } : {}), onPermission: (request, context) => { - if (!session?.turns.acceptsRequests) { + if (!isAcpReadySession(session) || !session.turns?.acceptsRequests) { // 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' } } @@ -165,11 +159,25 @@ export async function acquireAcpStructuredSession(input: { // 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 && + whenLane(() => { + if (slot.lane) { routeAcpSessionEvent({ lane: slot.lane, options }, event, now(), slot.reattaching) - ) + } + if ( + isAcpReadySession(session) && + !session.ended && + event.kind === 'known' && + event.notification.update.sessionUpdate === 'config_option_update' + ) { + deps.onEvent?.({ + type: 'options-reported', + sessionId, + fence: acquire.fence, + acquisitionGeneration: generation, + reportedOptions: options.read().current + }) + } + }) ) const identity = providerSpawnedProcessIdentity( acquire, @@ -177,7 +185,6 @@ export async function acquireAcpStructuredSession(input: { deps.readProcessStartTime ) /** `attaching`: the lane opens inside the attach window, before any frame queued so far. */ - let childStops = NO_ACP_CHILD_STOPS const makeLane = (providerSessionId: string, attaching = false): AcpStructuredLane => { const lane = new AcpStructuredLane({ sink, @@ -188,10 +195,14 @@ export async function acquireAcpStructuredSession(input: { providerSessionId, dialect: spec.dialect, now, - childStops: () => childStops, + childStops: () => session?.childStops ?? NO_ACP_CHILD_STOPS, ...acpLaneChildWorkDelivery(deps, sessionId), logger: deps.logger ?? createStructuredAgentSessionLogger(), - onInputAccepted: (clientMessageId) => session?.turns.accept(clientMessageId), + onInputAccepted: (clientMessageId) => { + if (isAcpReadySession(session)) { + session.turns?.accept(clientMessageId) + } + }, onFailed: () => input.forceClose(sessionId) }) slot.lane = lane @@ -212,114 +223,54 @@ export async function acquireAcpStructuredSession(input: { ) } await identity.onSpawned(pid) - const agentName = acpAgentName(spec.agent) - try { - 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 - let takeover: ReturnType | null = null - if (resume) { - const attaching = makeLane(resume.sessionId, true) - liveLane = attaching - try { - started = await connection.start({ - cwd: launch.cwd, - mcpServers: [], - sessionId: resume.sessionId, - ...auth - }) - slot.reattaching = false - attaching.translator.finishLoad() - } catch (error) { - 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) { - liveLane?.dispose() - slot.lane = null - started = await connection.start({ cwd: launch.cwd, mcpServers: [], ...auth }) - liveLane = makeLane(started.sessionId) - } - childStops = await probeAcpChildStops(connection, spec.dialect) - options.adoptSession(started.response, started.kind === 'new' ? 'new' : 'loaded') - liveLane.apply(liveLane.translator.contextModels(started.response.models, now())) - 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), - handle: { transport: ACP_HANDLE_TRANSPORT, agent: spec.agent, nativeId: started.sessionId }, - origin: started.kind === 'new' ? 'created' : 'resumed', - mintedAtFence: acquire.fence, - observedAt: now(), - ...takeover - } - session = { - sessionId, - fence: acquire.fence, - acquisitionGeneration: generation, - spec, - childStops, - connection, - lane: liveLane, - prompts, - options, - turns: new AcpStructuredTurns({ + const process = await identity.read(pid) + const child: AcpStructuredChild = { + sessionId, + fence: acquire.fence, + acquisitionGeneration: generation, + spec, + connection, + lane: null, + turns: null, + options, + prompts, + childStops: undefined, + restoreSkipped: [], + closeRequested: false, + journalClosed: null, + ended: false, + exitObservedAt: null, + open: (signal) => + initializeAcpStructuredSession({ + acquire, + deps, + generation, + launch, connection, - withdrawRequests: () => prompts.withdrawAll(), - lane: liveLane, - agentName, - now, - settle: input.onSettled + options, + prompts, + child, + abandoned: () => signal.aborted, + lanes: { + create: makeLane, + clear: () => { + slot.lane = null + }, + finishLoad: () => { + slot.reattaching = false + } + }, + onSettled: input.onSettled }), - restoreSkipped, - closeRequested: false, - journalClosed: null, - ended: false, - exitObservedAt: null + dispose: () => { + slot.lane?.dispose() + early.length = 0 } - const reading = liveLane - const unbind = events?.bindReadingControl?.({ - pauseReading: () => connection.pauseReading(), - resumeReading: () => { - connection.resumeReading() - reading.retry() - } - }) - if (unbind) { - session.unbindReadingControl = unbind - } - if (connection.exited || connection.closed) { - throw new Error(connection.stderrTail() || `${spec.command} exited while starting`) - } - // 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) { - session = null - slot.lane?.dispose() - if (error instanceof AcpAgentError && acpAuthenticationRequired(spec.dialect, error)) { - throw acpSignInRequiredRefusal(spec.agent, spec.dialect, error) - } - throw error + } + session = child + assertAcpStartingSession(child, input.abandoned) + return { + acquisition: { process, acquisitionGeneration: generation, providerChildPhase: 'starting' }, + session: child } } diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index fe80d84ddd1c..3620304c5cd7 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -1,29 +1,28 @@ +import { FakeAcpChild } from './acp-scripted-connection.test-fixture' +export { FakeAcpChild, PID } from './acp-scripted-connection.test-fixture' // 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 { z } from 'zod' 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 type { + StructuredAgentSessionLifecycleEvent, + StructuredAgentSessionStartedEvent +} 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, type AcpLaunchSpec } from './acp-launch-specs' -import { AcpScriptedAgent, tick, type FakeFrame } from './acp-scripted-agent.test-support' -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 AcpScriptedAgent, tick, type FakeFrame } from './acp-scripted-agent.test-support' 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 = [ { @@ -50,102 +49,22 @@ export const GROK_CONFIG_OPTIONS = [ } ] -/** 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 = '' - processTreeUnproven = false - private exitListeners: ((exit: ProviderProcessExit) => void)[] = [] - private gone = false - private closing = false - private lostWith: Error | undefined - closes = 0 - - 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: (exit: ProviderProcessExit) => void): void { - if (this.gone) { - listener(FAKE_EXIT) - } else { - this.exitListeners.push(listener) - } - } - stderrTail(): string { - return this.stderr - } - 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 - } - override close(error?: Error): Promise { - this.closes += 1 - this.closing ||= !this.gone - this.drainNotifications(error) - return this.proveClose().finally(() => super.close(error)) - } - /** The agent process ends on its own (or Orca's close landed). */ - exit(): void { - if (this.gone) { - return - } - this.gone = true - 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 child: () => FakeAcpChild spawned: string[] lifecycle: StructuredAgentSessionLifecycleEvent[] + readonly ended: Extract[] + started(): StructuredAgentSessionStartedEvent + openFailure(): unknown settled: Parameters>[0][] acquire(options?: { fence?: number onSpawned?: () => Promise signal?: AbortSignal + waitForStart?: boolean + options?: Readonly> }): ReturnType /** Frames Orca wrote to the agent with this method. */ sent(method: string): FakeFrame[] @@ -169,6 +88,8 @@ export async function openAcpAdapterRig( const spawned: string[] = [] const lifecycle: StructuredAgentSessionLifecycleEvent[] = [] const settled: AcpAdapterRig['settled'] = [] + let openFailure: unknown + const startListeners = new Set<() => void>() const spec = options.spec ?? GROK const resolveLaunch: AcpStructuredSessionAdapterDeps['resolveLaunch'] = options.deps?.resolveLaunch ?? @@ -208,19 +129,33 @@ export async function openAcpAdapterRig( }) }) const opened = { sessionId: PROVIDER_SESSION, configOptions: GROK_CONFIG_OPTIONS } + let 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/set_config_option', (frame) => { + const parsed = z.object({ configId: z.string(), value: z.string() }).parse(frame.params) + configOptions = configOptions.map((option) => + option.id === parsed.configId ? { ...option, currentValue: parsed.value } : option + ) + agent.reply(frame, { configOptions }) + }) 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, - ...options.deps + ...options.deps, + onEvent: (event) => { + lifecycle.push(event) + for (const listener of startListeners) { + listener() + } + options.deps?.onEvent?.(event) + } }) const frames = () => current?.agent.frames ?? [] return { @@ -234,9 +169,21 @@ export async function openAcpAdapterRig( }, spawned, lifecycle, + get ended() { + return lifecycle.filter((event) => event.type === 'ended') + }, + started: () => { + const event = lifecycle.findLast((entry) => entry.type === 'started') + if (event?.type !== 'started') { + throw new Error('ACP session has not started') + } + return event + }, + openFailure: () => openFailure, settled, - acquire: (acquireOptions = {}) => - adapter.acquire({ + acquire: async (acquireOptions = {}) => { + openFailure = undefined + const acquisition = await adapter.acquire({ identity: { sessionId: SESSION, workspaceId: 'workspace-1', @@ -247,12 +194,35 @@ export async function openAcpAdapterRig( fence: acquireOptions.fence ?? 1, spawnToken: 'spawn-1', events: rig.eventSink, + ...(acquireOptions.options ? { options: acquireOptions.options } : {}), ...(acquireOptions.signal ? { signal: acquireOptions.signal } : {}), onSpawned: async () => { spawned.push('onSpawned') await acquireOptions.onSpawned?.() } - }), + }) + if (acquireOptions.waitForStart !== false) { + try { + const opened = await adapter.open({ + sessionId: SESSION, + fence: acquireOptions.fence ?? 1, + acquisitionGeneration: acquisition.acquisitionGeneration ?? null, + signal: acquireOptions.signal ?? new AbortController().signal + }) + lifecycle.push({ + ...opened, + type: 'started', + sessionId: SESSION, + fence: acquireOptions.fence ?? 1, + acquisitionGeneration: acquisition.acquisitionGeneration ?? '' + }) + } catch (error) { + openFailure = error + await adapter.closeSession(SESSION) + } + } + return acquisition + }, sent: (method) => frames().filter((frame) => frame.method === method), frame: (method, index = 0) => waitFor(() => { diff --git a/src/main/acp/acp-structured-auth.test.ts b/src/main/acp/acp-structured-auth.test.ts index 92f27513cd51..2d83832ea434 100644 --- a/src/main/acp/acp-structured-auth.test.ts +++ b/src/main/acp/acp-structured-auth.test.ts @@ -4,7 +4,6 @@ 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' import { GROK_CONFIG_OPTIONS, @@ -60,9 +59,9 @@ async function signInWith(options: { initialize: { authMethods: options.authMethods }, script: requiresSignIn }) - const acquired = await rig.acquire().catch((error: unknown) => error) + await rig.acquire() const methods = rig.sent('authenticate').map((frame) => METHOD.parse(frame.params).methodId) - return { rig, acquired, methods } + return { rig, acquired: rig.openFailure() ?? rig.started(), methods } } describe('Grok sign-in on its own machine', () => { @@ -92,7 +91,6 @@ describe('Grok sign-in on its own machine', () => { authMethods: [{ id: 'browser', name: 'Browser' }] }) expect(methods).toEqual([]) - expect(acquired).toBeInstanceOf(AgentSessionAcquisitionRefusal) expect(acquired).toMatchObject({ reason: 'notSignedIn' }) }) diff --git a/src/main/acp/acp-structured-broken-stream.test.ts b/src/main/acp/acp-structured-broken-stream.test.ts index 5b2d6bf1e275..0f2bab38d3fe 100644 --- a/src/main/acp/acp-structured-broken-stream.test.ts +++ b/src/main/acp/acp-structured-broken-stream.test.ts @@ -113,11 +113,11 @@ describe('ACP agent that closes its stdout but keeps running', () => { // 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(rig.ended).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' }]) + expect(rig.ended).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) }, 30_000) }) @@ -153,8 +153,8 @@ describe('ACP agent that exits while a process it started holds its stdout open' }) 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' }) + await vi.waitFor(() => expect(rig.ended).toHaveLength(1), { timeout: 10_000, interval: 20 }) + expect(rig.ended[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' }) diff --git a/src/main/acp/acp-structured-child-lifecycle.ts b/src/main/acp/acp-structured-child-lifecycle.ts new file mode 100644 index 000000000000..ea2a95243f20 --- /dev/null +++ b/src/main/acp/acp-structured-child-lifecycle.ts @@ -0,0 +1,139 @@ +import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { ProviderAcquisitionStarts } from '../provider-process/provider-acquisition-starts' +import { isAcpReadySession, type AcpStructuredChild } from './acp-structured-child' +import type { AcpStructuredConnection } from './acp-structured-connection' +import { closeAcpSessionJournal, endAcpStructuredSession } from './acp-structured-session' +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' +import { + ACP_STOP_GRACE_MS, + type AcpStructuredSessionAdapterDeps +} from './acp-structured-session-adapter-deps' +import { windDownAcpTurn } from './acp-structured-stop' + +export class AcpStructuredChildren { + readonly sessions = new Map() + readonly starts = new ProviderAcquisitionStarts() + constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {} + /** A requested close: proven by the root's exit; a tree not proven gone is the caller's to report. */ + async close(sessionId: string): Promise { + const connection = this.sessions.get(sessionId)?.connection + const closed = await this.stop(sessionId, true) + if (closed && connection?.processTreeUnproven) { + 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: + * a failed start's child, and the session's own. */ + async stop(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 + } + if (!isAcpReadySession(session)) { + session.closeRequested ||= requested && session.journalClosed === null + session.dispose() + session.closing ??= session.connection + .close() + .then((proven) => { + if (proven) { + this.finish(session, session.exitObservedAt ?? this.now()) + } + return proven + }) + .finally(() => { + session.closing = undefined + }) + return session.closing + } + 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 + session.lane.flush() + } + const proven = await session.connection.close() + if (proven) { + 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. */ + finish(session: AcpStructuredChild, observedAt: number): void { + if (isAcpReadySession(session)) { + endAcpStructuredSession(session, observedAt, this.deps.onEvent) + } else if (!session.ended) { + session.ended = true + session.exitObservedAt = observedAt + session.dispose() + this.deps.onEvent?.({ + type: 'ended', + sessionId: session.sessionId, + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration, + observedAt, + cause: session.closeRequested ? 'requested-close' : 'unexpected-exit', + reason: + session.connection.stderrTail() || `${session.spec.agent} ACP agent ended while starting`, + failure: agentSessionFailureFact( + session.closeRequested ? 'hostStopped' : 'providerExited', + { + detail: providerDiagnostic(session.connection.stderrTail(), 'person') + } + ) + }) + } + 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. */ + connectionLost(session: AcpStructuredChild | null, error: Error): void { + if ( + !session || + this.sessions.get(session.sessionId) !== session || + session.journalClosed !== null + ) { + return + } + if (!isAcpReadySession(session)) { + session.journalClosed = error.message || error.name + return + } + closeAcpSessionJournal( + session, + `${session.spec.agent} ACP connection closed: ${error.message || error.name}` + ) + void this.stop(session.sessionId, false) + } + + 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-child-stop.ts b/src/main/acp/acp-structured-child-stop.ts index 2ed2f7c40a85..e1c28fdf8258 100644 --- a/src/main/acp/acp-structured-child-stop.ts +++ b/src/main/acp/acp-structured-child-stop.ts @@ -1,3 +1,4 @@ +import { isAcpReadySession, type AcpStructuredChild } from './acp-structured-child' import type { AcpStructuredSession } from './acp-structured-session' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpChildStops, AcpDialect } from './acp-dialects/acp-dialect' @@ -20,12 +21,13 @@ type BackgroundTaskStop = NonNullable export const NO_ACP_CHILD_STOPS: AcpChildStops = { subagents: false, backgroundTasks: false } export function acpChildStopCapabilities( - session?: AcpStructuredSession + session?: AcpStructuredChild ): AgentSessionBackgroundTaskStops | undefined { return session?.journalClosed === null ? { supportsTaskStop: - session.childStops?.subagents === true || session.childStops?.backgroundTasks === true, + isAcpReadySession(session) && + (session.childStops?.subagents === true || session.childStops?.backgroundTasks === true), supportsStopAll: false } : undefined diff --git a/src/main/acp/acp-structured-child.ts b/src/main/acp/acp-structured-child.ts new file mode 100644 index 000000000000..4602b1738f9b --- /dev/null +++ b/src/main/acp/acp-structured-child.ts @@ -0,0 +1,64 @@ +import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' +import { AcpAgentError } from './acp-errors' +import { acpAuthenticationRequired, acpSignInRequiredFailure } from './acp-turn-failures' +import type { AcpStructuredSession } from './acp-structured-session' + +export type AcpStructuredChild = Pick< + AcpStructuredSession, + | 'sessionId' + | 'fence' + | 'acquisitionGeneration' + | 'spec' + | 'connection' + | 'closeRequested' + | 'journalClosed' + | 'ended' + | 'exitObservedAt' +> & { + lane: AcpStructuredSession['lane'] | null + turns: AcpStructuredSession['turns'] | null + options: AcpStructuredSession['options'] + prompts: AcpStructuredSession['prompts'] + childStops?: AcpStructuredSession['childStops'] + restoreSkipped: readonly string[] + unbindReadingControl?: () => void + open: (signal: AbortSignal) => Promise + closing?: Promise + dispose: () => void +} + +export function isAcpReadySession( + session: AcpStructuredChild | null | undefined +): session is AcpStructuredSession & AcpStructuredChild { + return session != null && session.lane !== null && session.turns !== null +} + +export function acpStartupFailure( + session: AcpStructuredChild, + error: unknown +): SubmissionRejectionFact | undefined { + if (error instanceof AcpAgentError && acpAuthenticationRequired(session.spec.dialect, error)) { + // The agent's sign-in words, never log lines it wrote before refusing. + return acpSignInRequiredFailure(session.spec.dialect, error) + } + return undefined +} + +export function assertAcpStartingSession( + session: AcpStructuredChild, + abandoned: () => boolean +): void { + if ( + session.ended || + session.closeRequested || + session.journalClosed !== null || + session.connection.exited || + session.connection.closed || + abandoned() + ) { + throw new Error( + session.connection.stderrTail() || `${session.spec.command} closed while starting` + ) + } +} diff --git a/src/main/acp/acp-structured-compaction.test.ts b/src/main/acp/acp-structured-compaction.test.ts index 297a076816ca..e813539cc022 100644 --- a/src/main/acp/acp-structured-compaction.test.ts +++ b/src/main/acp/acp-structured-compaction.test.ts @@ -8,7 +8,6 @@ import { import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' -import { structuredAgentSessionChildReportedSignedOut } from '../native-chat/agent-session-wire/structured-agent-session-outgrown-child' import { ACP_LAUNCH_SPECS, acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' import { acpStructuredAgentDefinition } from './acp-structured-agent-definitions' import { GrokFixtureReplay } from './acp-structured-fixture-replay.test-support' @@ -276,27 +275,18 @@ describe('ACP compaction settlement', () => { it.each([ { spec: OPENCODE, code: -32000, data: undefined }, { spec: OMP, code: -32603, data: { details: 'No model selected.\n\nUse /login to sign in.' } } - ])( - 'says $spec.agent is not signed in, as a send does, so the next one starts a new agent', - async ({ spec, code, data }) => { - const { rig, command, prompt } = await compact(spec) - rig.child().agent.fail(prompt, code, 'Authentication required', data) - const { turn, inside } = await ended(rig, command) - expect(turn).toMatchObject({ state: 'completed', outcome: 'failure' }) - expect(inside).toEqual([ - expect.objectContaining({ - tone: 'error', - failure: expect.objectContaining({ kind: 'notSignedIn' }) - }) - ]) - expect( - structuredAgentSessionChildReportedSignedOut({ - child: { generation: 'generation-1', fence: 1, phase: 'ready' }, - journal: rig.rig.journal - }) - ).toBe(true) - } - ) + ])('says $spec.agent is not signed in, as a send does', async ({ spec, code, data }) => { + const { rig, command, prompt } = await compact(spec) + rig.child().agent.fail(prompt, code, 'Authentication required', data) + const { turn, inside } = await ended(rig, command) + expect(turn).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(inside).toEqual([ + expect.objectContaining({ + tone: 'error', + failure: expect.objectContaining({ kind: 'notSignedIn' }) + }) + ]) + }) it('fails a compaction the agent ended with any other stop reason', async () => { const { rig, command, prompt } = await compact(GROK) diff --git a/src/main/acp/acp-structured-handshake.ts b/src/main/acp/acp-structured-handshake.ts new file mode 100644 index 000000000000..1d61e51a8240 --- /dev/null +++ b/src/main/acp/acp-structured-handshake.ts @@ -0,0 +1,161 @@ +// An ACP child's handshake, run after the host has published it: initialize, reopen the chat's +// session or start a new one, then return its protocol facts to the host. A saved session the +// agent cannot reopen is replaced by a new one, with a warning row that any later start writes if +// this one never did; while a reopened session replays history the journal already holds, nothing +// it sends is written except context usage. + +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' +import { + ACP_REOPEN_FAILED, + acpReopenTakeover, + acpSessionNotRestoredRow +} from './acp-session-reopen-failure' +import { ACP_HANDLE_TRANSPORT, acpAgentName } from './acp-structured-agent-definitions' +import { probeAcpChildStops } from './acp-structured-child-stop' +import type { AcpStructuredConnection } from './acp-structured-connection' +import type { AcpStructuredLane } from './acp-structured-lane' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +import { restoreAcpSessionOptions, type AcpStructuredOptions } from './acp-structured-options' +import type { AcpStructuredPrompts } from './acp-structured-prompts' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' +import { AcpStructuredTurns, type AcpStructuredTurnsDeps } from './acp-structured-turns' +import { + assertAcpStartingSession, + isAcpReadySession, + type AcpStructuredChild +} from './acp-structured-child' + +type AcpHandshakeInput = { + acquire: StructuredAgentSessionAcquireInput + deps: AcpStructuredSessionAdapterDeps + generation: string + launch: AcpStructuredLaunch + connection: AcpStructuredConnection + options: AcpStructuredOptions + prompts: AcpStructuredPrompts + child: AcpStructuredChild + abandoned: () => boolean + lanes: { + create: (providerSessionId: string, attaching?: boolean) => AcpStructuredLane + clear: () => void + finishLoad: () => void + } + onSettled: AcpStructuredTurnsDeps['settle'] +} + +export async function initializeAcpStructuredSession( + input: AcpHandshakeInput +): Promise { + const { acquire, deps, launch, connection, options, prompts, child, onSettled } = input + const { spec } = deps + const sessionId = acquire.identity.sessionId + const now = deps.now ?? Date.now + const assertStarting = () => assertAcpStartingSession(child, input.abandoned) + const agentName = acpAgentName(spec.agent) + assertStarting() + const initialized = await connection.initialize() + assertStarting() + // 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 + let takeover: ReturnType | null = null + if (resume) { + const attaching = input.lanes.create(resume.sessionId, true) + liveLane = attaching + try { + started = await connection.start({ + cwd: launch.cwd, + mcpServers: [], + sessionId: resume.sessionId, + ...auth + }) + assertStarting() + input.lanes.finishLoad() + attaching.translator.finishLoad() + } catch (error) { + assertStarting() + takeover = acpReopenTakeover(error, resume, { + over: connection.closed || input.abandoned(), + 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) { + liveLane?.dispose() + input.lanes.clear() + started = await connection.start({ cwd: launch.cwd, mcpServers: [], ...auth }) + assertStarting() + liveLane = input.lanes.create(started.sessionId) + } + const childStops = await probeAcpChildStops(connection, spec.dialect) + assertStarting() + options.adoptSession(started.response, started.kind === 'new' ? 'new' : 'loaded') + liveLane.apply(liveLane.translator.contextModels(started.response.models, now())) + const restoreSkipped = await restoreAcpSessionOptions(connection, options, acquire.options) + assertStarting() + 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(), + ...takeover + } + const reading = liveLane + const unbind = acquire.events?.bindReadingControl?.({ + pauseReading: () => connection.pauseReading(), + resumeReading: () => { + connection.resumeReading() + reading.retry() + } + }) + if (unbind) { + child.unbindReadingControl = unbind + } + if (connection.exited || connection.closed) { + throw new Error(connection.stderrTail() || `${spec.command} exited while starting`) + } + // 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)) + } + assertStarting() + Object.assign(child, { + childStops, + lane: liveLane, + turns: new AcpStructuredTurns({ + connection, + withdrawRequests: () => prompts.withdrawAll(), + lane: liveLane, + agentName, + now, + settle: onSettled + }), + restoreSkipped + }) + if (!isAcpReadySession(child)) { + throw new Error('ACP session did not open') + } + return { + link, + reportedOptions: options.read().current, + // Its saved picks restored: what it runs now is what its start resolved. + catalogListing: options.startListing() + } +} diff --git a/src/main/acp/acp-structured-host-configured-default.test.ts b/src/main/acp/acp-structured-host-configured-default.test.ts index f37dbafac6df..b0e19d5f14a0 100644 --- a/src/main/acp/acp-structured-host-configured-default.test.ts +++ b/src/main/acp/acp-structured-host-configured-default.test.ts @@ -148,6 +148,10 @@ async function openGrokHost(runs: string, create?: StructuredAgentSessionFirstMe ? rig.host.create(CALLER, createTestParams(create, attachParams()), { firstMessage: create }) : rig.host.attach(CALLER, attachParams()) expect(await opening).toMatchObject({ ok: true }) + if (!create) { + // The attach answers at spawn; what the chat runs is known once its start is proven. + await rig.ready() + } return { ...rig, catalog, listed } } diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index a1486cd3263e..d0e667548008 100644 --- a/src/main/acp/acp-structured-host-lifecycle.test.ts +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -57,18 +57,20 @@ 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({ + const { host, fence, messages, exchange, ready } = await openHostRig({ initialize: RESUMES, script: loads('hi', count), deps: { resolveLaunch: launch(() => resumed) } }) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await ready() 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 ready() await host.flushStreamedEvents(SESSION) expect(count.loads).toBe(1) expect(await messages()).toEqual(['hello', 'hi']) @@ -82,12 +84,13 @@ describe('reopening a Grok chat through the host', () => { it('shows a reply a crash cut off with the existing notice, never completed from what Grok saved', async () => { let resumed = false - const { rig, host, fence, messages, exchange } = await openHostRig({ + const { rig, host, fence, messages, exchange, ready } = await openHostRig({ initialize: RESUMES, script: loads('complete saved reply', { loads: 0 }), deps: { resolveLaunch: launch(() => resumed) } }) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await ready() resumed = true await exchange('complete', false) // Grok dies mid-reply; the host ends its record, which moves the chat's fence. @@ -95,6 +98,7 @@ describe('reopening a Grok chat through the host', () => { rig.child().exit() await waitFor(() => expect(fence()).toBeGreaterThan(cutAt)) expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() await host.flushStreamedEvents(SESSION) expect(await messages()).toEqual(['hello', 'complete']) const rows = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items @@ -169,29 +173,28 @@ 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; a close leaves it, the next start asks it again', async () => { - const { rig, host } = await openHostRig({ - script: (agent) => agent.on('initialize', () => {}) - }) - const attaching = host.attach(CALLER, attachParams()).catch((error: unknown) => error) + it('retains a published startup child whose close is unproven and retries before another spawn', async () => { + const { rig, host } = await openHostRig({ script: (agent) => agent.on('initialize', () => {}) }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) await rig.frame('initialize') const child = rig.child() child.proveClose = vi.fn(async () => false) - await host.close(SESSION, 'user-close') - expect(await attaching).toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) - child.proveClose = vi.fn(async () => { - child.exit() - return true - }) - await host.close(SESSION, 'user-close') - expect(child.proveClose).not.toHaveBeenCalled() - expect(child.exited).toBe(false) - // As Claude's: the next start asks the child again before it spawns another. + try { + await expect(host.close(SESSION, 'user-close')).rejects.toThrow('eviction failed') + expect(child.exited).toBe(false) + expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) + } finally { + child.proveClose = vi.fn(async () => { + child.exit() + return true + }) + await host.close(SESSION, 'user-close') + } + expect(child.exited).toBe(true) 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') }) @@ -205,7 +208,7 @@ describe('closing or stopping a Grok chat while it starts', () => { const stopping = host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) // With no close behind it. await waitFor(() => expect(rig.child().exited).toBe(true)) - expect((await attaching).ok).toBe(false) + expect((await attaching).ok).toBe(true) expect(await stopping).toMatchObject({ ok: true }) expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) }) @@ -244,7 +247,7 @@ describe('closing or stopping a Grok chat while it starts', () => { await host.close(SESSION, 'user-close') }) - it('leaves a start alone for a Stop that names a turn of a child already gone', async () => { + it('leaves a starting child alone when Stop names an earlier turn', async () => { const { rig, host } = await openHostRig({ script: (agent) => agent.on('initialize', () => {}) }) @@ -254,11 +257,12 @@ describe('closing or stopping a Grok chat while it starts', () => { envelope: envelope('agentSession.cancel', { turnId: 'turn-of-an-old-child' }), turnId: 'turn-of-an-old-child' }) + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: false } }) await rig.settle() expect(rig.child().closes).toBe(0) + expect(rig.child().exited).toBe(false) 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 () => { diff --git a/src/main/acp/acp-structured-host-live-listing.test.ts b/src/main/acp/acp-structured-host-live-listing.test.ts index f077684b1a41..f62aa69fa143 100644 --- a/src/main/acp/acp-structured-host-live-listing.test.ts +++ b/src/main/acp/acp-structured-host-live-listing.test.ts @@ -45,9 +45,18 @@ describe('a live Grok listing', () => { stop: vi.fn(), providerStarted: vi.fn() } - // The rig's attach must succeed: bookkeeping never fails a proven start. - const { store } = await openAttachedHostRig({}, undefined, modelCatalog) + // The rig waits for the child to turn ready: bookkeeping never fails a proven start. + const { host, rig, store, fence } = await openAttachedHostRig({}, undefined, modelCatalog) expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(1) expect(store.getRecord(SESSION)?.lease.ownerProcess).not.toBeNull() + // The rest of the start still lands, and live options remain readable. + await expect(host.readOptions(SESSION)).resolves.toMatchObject({ current: expect.any(Object) }) + expect( + (await rig.adapter.readOptions({ sessionId: SESSION, fence: fence() })).current + ).toMatchObject({ + model: 'grok-4.7', + effort: 'high' + }) + expect(store.getRecord(SESSION)?.options).toBeUndefined() }) }) diff --git a/src/main/acp/acp-structured-host-option-hang.test.ts b/src/main/acp/acp-structured-host-option-hang.test.ts index 32f0a6df64f6..86db37f29252 100644 --- a/src/main/acp/acp-structured-host-option-hang.test.ts +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -49,7 +49,9 @@ const failed = (outcome: Awaited>) => 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 { rig, host } = await openAttachedHostRig({ + script: (agent) => agent.on('session/set_config_option', () => {}) + }) 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' }) @@ -58,7 +60,9 @@ describe('a Grok model pick Grok never answers', () => { }) it('does not hold a Close behind it; the pick fails', async () => { - const { rig, host } = await openAttachedHostRig() + const { rig, host } = await openAttachedHostRig({ + script: (agent) => agent.on('session/set_config_option', () => {}) + }) 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({ @@ -68,21 +72,34 @@ describe('a Grok model pick Grok never answers', () => { }) 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)) + const { rig, host, store, fence } = await openAttachedHostRig({ + optionWriteTimeoutMs: 100 + }) + expect(await pick(host, 'grok-4.6')).toMatchObject({ ok: true }) + const savedModel = store.getRecord(SESSION)?.options?.model + expect(savedModel).toBe('grok-4.6') + const priorWrites = framesOf(rig.child(), 'session/set_config_option').length + rig.child().agent.on('session/set_config_option', () => {}) + const picking = pick(host, 'grok-4.7') + await waitFor(() => + expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(priorWrites + 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') + const frame = framesOf(rig.child(), 'session/set_config_option').at(-1) rig.child().agent.reply(frame!, { configOptions: GROK_CONFIG_OPTIONS.map((option) => - option.id === 'model' ? { ...option, currentValue: 'grok-4.6' } : option + option.id === 'model' ? { ...option, currentValue: 'grok-4.7' } : option ) }) await waitFor(async () => - expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' }) + expect( + (await rig.adapter.readOptions({ sessionId: SESSION, fence: fence() })).current.model + ).toBe('grok-4.7') ) + expect((await host.readOptions(SESSION)).current.model).toBe(savedModel) + expect(store.getRecord(SESSION)?.options?.model).toBe(savedModel) await host.close(SESSION, 'user-close') }) }) 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 f4024117ec9d..bae22410c7d9 100644 --- a/src/main/acp/acp-structured-host-restore-failed.test.ts +++ b/src/main/acp/acp-structured-host-restore-failed.test.ts @@ -120,14 +120,16 @@ async function openRestoreRig(code: number, message: 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 { host, store, fence, loads, warnings, exchange } = await openRestoreRig( + const { host, store, fence, loads, warnings, ready, exchange } = await openRestoreRig( -32603, 'session file is corrupt' ) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await ready() await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION]) const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] @@ -160,6 +162,7 @@ describe('a saved Grok session Grok cannot reopen', () => { // 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 }) + await ready() expect(loads).toEqual([PROVIDER_SESSION, FRESH]) expect(store.getRecord(SESSION)?.providerHandleChain).toHaveLength(3) expect(await warnings()).toHaveLength(1) @@ -169,14 +172,16 @@ describe('a saved Grok session Grok cannot reopen', () => { 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( + const { host, store, fence, loads, warnings, ready, exchange, messages } = await openRestoreRig( -32002, 'Resource not found' ) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await ready() await exchange('remember 42', 'noted', PROVIDER_SESSION) await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION]) const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] @@ -195,19 +200,22 @@ describe('a created Grok session Grok reports missing on the first reopen', () = // 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 }) + await ready() 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( + const { host, store, fence, loads, warnings, ready } = await openRestoreRig( -32002, 'Resource not found' ) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await ready() await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION]) const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] @@ -224,13 +232,14 @@ describe('a created Grok session Grok reports missing on the first reopen', () = }) }) -describe('the warning row of a replacement whose attach failed', () => { - async function replaceThenFailAttach() { +describe('a replacement after its earlier attach failed to open the journal', () => { + async function failAttachBeforeReplacement() { const opened = await openRestoreRig(-32603, 'session file is corrupt') - const { host, store, fence, warnings } = opened + const { host, store, fence, warnings, ready } = opened expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await ready() await host.close(SESSION, 'user-close') - // The journal's open fails after the fresh session's link is durable, before its row is written. + // The journal must open before a spawned child's background handshake proves a new link. vi.spyOn(AgentSessionJournal.prototype, 'open').mockRejectedValueOnce( new Error('journal path unavailable') ) @@ -240,32 +249,37 @@ describe('the warning row of a replacement whose attach failed', () => { expect( agentSessionProviderHandleChainHead(store.getRecord(SESSION)?.providerHandleChain ?? []) ).toMatchObject({ - handle: { nativeId: FRESH }, - replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' } + handle: { nativeId: PROVIDER_SESSION } }) 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() + it('writes the warning when the next start replaces the old session, and only once', async () => { + const { host, fence, loads, warnings, ready } = await failAttachBeforeReplacement() expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) - expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + await ready() + expect(loads).toEqual([PROVIDER_SESSION]) 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]) + await ready() + expect(loads).toEqual([PROVIDER_SESSION, 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() + const { host, store, fence, loads, lost, warnings, ready } = await failAttachBeforeReplacement() + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() + 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 }) + await ready() expect(loads).toEqual([PROVIDER_SESSION, FRESH]) const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] expect(chain).toHaveLength(2) @@ -277,6 +291,7 @@ describe('the warning row of a replacement whose attach failed', () => { expect(await warnings()).toHaveLength(1) await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH_2]) expect(await warnings()).toHaveLength(1) await host.close(SESSION, 'user-close') @@ -285,23 +300,27 @@ describe('the warning row of a replacement whose attach failed', () => { 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( + const { host, fence, loads, lost, warnings, ready } = await openRestoreRig( -32603, 'session file is corrupt' ) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await ready() await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() 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 }) + await ready() 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 }) + await ready() 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-start-aborts.test.ts b/src/main/acp/acp-structured-host-start-aborts.test.ts index 216a495e1bc9..a9f846455a10 100644 --- a/src/main/acp/acp-structured-host-start-aborts.test.ts +++ b/src/main/acp/acp-structured-host-start-aborts.test.ts @@ -70,7 +70,7 @@ describe('a Grok start the host aborts', () => { await rig.frame('initialize') // 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((await attaching).ok).toBe(true) expect(rig.child().exited).toBe(true) await rig.adapter.closeAll() }) @@ -180,6 +180,7 @@ async function openClosedResumableChat(options: { hangsHandshake?: number } = {} deps: { resolveLaunch: launch(() => resumed) } }) expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await rig.ready() resumed = true await rig.host.close(SESSION, 'user-close') return { ...rig, children: () => children } diff --git a/src/main/acp/acp-structured-host-start-failure.test.ts b/src/main/acp/acp-structured-host-start-failure.test.ts new file mode 100644 index 000000000000..74fac5c26d67 --- /dev/null +++ b/src/main/acp/acp-structured-host-start-failure.test.ts @@ -0,0 +1,31 @@ +import { afterEach, 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 { 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 } from './acp-structured-host.test-support' + +afterEach(closeProviderTimelineRigs) + +it('tells queued messages the agent is signed out when its published startup fails authentication', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('session/new', () => {}) + }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + const request = await rig.frame('session/new') + const messageId = await send(host, 'held during sign-in') + expect(rig.sent('session/prompt')).toEqual([]) + rig.child().agent.fail(request, -32000, 'Authentication required') + await waitFor(async () => { + const snapshot = await host.journalSnapshot(SESSION) + const submission = snapshot.submissions.find((entry) => entry.clientMessageId === messageId) + expect(submission?.handedOverAt).toBeUndefined() + expect(submission).toMatchObject({ + dispatchState: 'rejected', + rejection: { kind: 'notSignedIn' }, + reason: expect.stringMatching(/^Sign in to Grok/) + }) + }) + expect(rig.child().exited).toBe(true) + expect(rig.sent('session/prompt')).toEqual([]) +}) diff --git a/src/main/acp/acp-structured-host.test-support.ts b/src/main/acp/acp-structured-host.test-support.ts index e9ebec7d8855..261787f1a944 100644 --- a/src/main/acp/acp-structured-host.test-support.ts +++ b/src/main/acp/acp-structured-host.test-support.ts @@ -30,6 +30,7 @@ import { openAcpAdapterRig, PROVIDER_SESSION, replyChunk, + waitFor, type FakeAcpChild } from './acp-structured-adapter.test-support' import { @@ -126,12 +127,17 @@ export async function openHostRig( hosted.host = host replaceHostTestState({ store, host }) const fence = () => store.getRecord(SESSION)?.lease.runtimeFence ?? 1 + const ready = () => + waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) 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) => { + await ready() const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal await journal.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, { fence: fence(), @@ -151,7 +157,7 @@ export async function openHostRig( await rig.settle() await host.flushStreamedEvents(SESSION) } - return { rig, host, store, journalDatabase, fence, messages, exchange } + return { rig, host, store, journalDatabase, fence, messages, exchange, ready } } /** Grok's capabilities: it loads and resumes sessions; Orca reopens with `session/load`. */ @@ -187,24 +193,30 @@ export const framesOf = (child: FakeAcpChild, method: string) => /** A new Grok chat the host attached; Grok loads its session on a later start, counting each. */ export async function openAttachedHostRig( - deps: Partial = {}, + deps: Partial & { + script?: (agent: AcpScriptedAgent) => void + } = {}, statusSink?: StructuredAgentSessionStatusSink, modelCatalog?: AgentModelCatalogService ) { + const { script, ...adapterDeps } = deps const count = { loads: 0 } let resumed = false const rig = await openHostRig({ statusSink, ...(modelCatalog ? { modelCatalog } : {}), initialize: RESUMES, - script: (agent) => + script: (agent) => { agent.on('session/load', (frame) => { count.loads += 1 agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) - }), - deps: { resolveLaunch: launch(() => resumed), ...deps } + }) + script?.(agent) + }, + deps: { resolveLaunch: launch(() => resumed), ...adapterDeps } }) expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await rig.ready() resumed = true const rows = async () => { await rig.host.flushStreamedEvents(SESSION) diff --git a/src/main/acp/acp-structured-missing-model.test.ts b/src/main/acp/acp-structured-missing-model.test.ts index 738ba92bf33c..c8df21f6ce6e 100644 --- a/src/main/acp/acp-structured-missing-model.test.ts +++ b/src/main/acp/acp-structured-missing-model.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import { readNativeSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' +import { nativeSessionOptionsFromReport } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' import { replaceAgentSessionRecordOptions } from '../runtime/agent-session-record-options' import { agentSessionStoreDraftRowWrites } from '../runtime/agent-session-store-draft' import type { AgentSessionStoreState } from '../runtime/agent-session-store-state' @@ -27,7 +27,7 @@ const modelOption: SessionConfigOption = { options: [{ value: 'reported-model', name: 'Reported Model' }] } -async function writtenOptions(provider: string, reader: AcpStructuredOptions) { +function writtenOptions(provider: string, reader: AcpStructuredOptions) { const fixture = agentSessionRecordFixture() const record = { ...fixture, @@ -45,14 +45,11 @@ async function writtenOptions(provider: string, reader: AcpStructuredOptions) { unreadableRecords: new Map(), sessionTabs: null } - const options = await readNativeSessionOptions({ - adapter: { readOptions: async () => reader.read() }, - sessionId: record.sessionId, - fence: record.lease.runtimeFence + // What the host writes from the report a started child sends. + const options = nativeSessionOptionsFromReport({ + reported: reader.read().current, + restoreSkipped: [] }) - if (!options) { - throw new Error('The live reader must report its options') - } const changed = replaceAgentSessionRecordOptions(record, { sessionId: record.sessionId, fence: record.lease.runtimeFence, @@ -79,14 +76,14 @@ describe('ACP sessions without a reported model', () => { current: { effort: 'off', confirmed: ['effort'] } }) expect(reader.reported()).toEqual({ effort: 'off' }) - expect(await writtenOptions('omp', reader)).toEqual({ effort: 'off' }) + expect(writtenOptions('omp', reader)).toEqual({ effort: 'off' }) }) it.each(['grok', 'opencode'])('writes missing-model options for %s', async (provider) => { const reader = new AcpStructuredOptions() reader.adoptSession({}) expect(reader.read().current).not.toHaveProperty('model') - expect(await writtenOptions(provider, reader)).toEqual({}) + expect(writtenOptions(provider, reader)).toEqual({}) }) it.each(['config', 'legacy'] as const)('does not expose an empty %s model value', (source) => { @@ -103,13 +100,13 @@ describe('ACP sessions without a reported model', () => { it('records a model reported after a startup with no model', async () => { const reader = new AcpStructuredOptions() reader.adoptSession(response) - expect(await writtenOptions('omp', reader)).toEqual({ effort: 'off' }) + expect(writtenOptions('omp', reader)).toEqual({ effort: 'off' }) reader.adoptConfigOptions([...(response.configOptions ?? []), modelOption]) expect(reader.read().current).toEqual({ model: 'reported-model', effort: 'off', confirmed: ['model', 'effort'] }) - expect(await writtenOptions('omp', reader)).toEqual({ model: 'reported-model', effort: 'off' }) + expect(writtenOptions('omp', reader)).toEqual({ model: 'reported-model', effort: 'off' }) }) }) diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index 443ff17fdfb2..ad17dd78cf4d 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -276,8 +276,10 @@ export async function restoreAcpSessionOptions( saved: Readonly> | undefined ): Promise { const skipped: string[] = [] - for (const [key, value] of Object.entries(saved ?? {})) { - if (!isAcpStructuredOptionKey(key)) { + // Model before effort: an effort menu belongs to the model it is set on. + for (const key of ['model', 'effort']) { + const value = saved?.[key] + if (value === undefined || !isAcpStructuredOptionKey(key)) { continue } options.notePick(key) diff --git a/src/main/acp/acp-structured-publish-at-spawn.test.ts b/src/main/acp/acp-structured-publish-at-spawn.test.ts new file mode 100644 index 000000000000..957c2b356584 --- /dev/null +++ b/src/main/acp/acp-structured-publish-at-spawn.test.ts @@ -0,0 +1,192 @@ +import { z } from 'zod' +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + GROK_CONFIG_OPTIONS, + openAcpAdapterRig, + PROVIDER_SESSION, + waitFor +} from './acp-structured-adapter.test-support' +import type { FakeFrame } from './acp-scripted-agent.test-support' + +afterEach(closeProviderTimelineRigs) +const configParams = z.object({ configId: z.string(), value: z.string() }) + +describe('ACP child published before its handshake', () => { + it('returns at spawn, then reports its real session and restored picks in model-before-effort order', async () => { + let answer: (() => void) | undefined + const writes: string[] = [] + let configOptions = GROK_CONFIG_OPTIONS + const rig = await openAcpAdapterRig({ + script: (agent) => { + agent.on('session/new', (frame) => { + answer = () => agent.reply(frame, { sessionId: PROVIDER_SESSION, configOptions }) + }) + agent.on('session/set_config_option', (frame) => { + const { configId: key, value } = configParams.parse(frame.params) + writes.push(key) + configOptions = configOptions.map((option) => + option.id === key ? { ...option, currentValue: value } : option + ) + agent.reply(frame, { configOptions }) + }) + } + }) + const acquired = await rig.acquire({ + waitForStart: false, + options: { effort: 'low', model: 'grok-4.6' } + }) + expect(acquired.process.pid).toBe(4242) + if (!acquired.acquisitionGeneration) { + throw new Error('missing acquisition generation') + } + expect(acquired.link).toBeUndefined() + expect(rig.adapter.holdsLiveProviderProcess(SESSION, acquired.acquisitionGeneration)).toBe(true) + expect(rig.adapter.holdsDispatch(SESSION)).toBe(false) + expect(rig.adapter.readCommands(SESSION)).toBeUndefined() + const opening = rig.adapter.open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration, + signal: new AbortController().signal + }) + await rig.frame('session/new') + expect(rig.lifecycle).toEqual([]) + answer?.() + expect(await opening).toMatchObject({ + link: { handle: { nativeId: PROVIDER_SESSION }, origin: 'created' }, + reportedOptions: { model: 'grok-4.6', effort: 'low' } + }) + expect(writes).toEqual(['model', 'reasoning_effort']) + rig.child().agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'config_option_update', configOptions: GROK_CONFIG_OPTIONS } + }) + await waitFor(() => + expect(rig.lifecycle.map((event) => event.type)).toEqual(['options-reported']) + ) + }) + + it.each(['closeSession', 'disposeSession', 'forceCloseSession', 'releaseAcquisition'] as const)( + 'lets %s stop the starting process without a late start', + async (method) => { + let frame: FakeFrame | undefined + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/new', (request) => { + frame = request + }) + }) + const acquired = await rig.acquire({ waitForStart: false }) + const opening = rig.adapter + .open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + .catch((error: unknown) => error) + await rig.frame('session/new') + if (!acquired.acquisitionGeneration) { + throw new Error('missing acquisition generation') + } + const closed = + method === 'releaseAcquisition' + ? rig.adapter[method]({ sessionId: SESSION }) + : rig.adapter[method](SESSION) + expect(await closed).toBe(true) + expect(await opening).toBeInstanceOf(Error) + if (frame) { + rig.child().agent.reply(frame, { sessionId: PROVIDER_SESSION }) + } + await rig.settle() + expect(rig.lifecycle.map((event) => event.type)).toEqual(['ended']) + expect(rig.adapter.holdsLiveProviderProcess(SESSION, acquired.acquisitionGeneration)).toBe( + false + ) + } + ) + + it.each([true, false])( + 'reports a startup exit with initialize answered = %s', + async (answered) => { + const rig = await openAcpAdapterRig({ + script: (agent) => agent.on(answered ? 'session/new' : 'initialize', () => {}) + }) + await rig.acquire({ waitForStart: false }) + const opening = rig.adapter + .open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: 'gen-acp', + signal: new AbortController().signal + }) + .catch((error: unknown) => error) + await rig.frame(answered ? 'session/new' : 'initialize') + rig.child().stderr = 'provider config is invalid' + rig.child().exit() + expect(await opening).toBeInstanceOf(Error) + expect(rig.ended).toMatchObject([ + { failure: { detail: { text: 'provider config is invalid' } } } + ]) + expect(rig.ended[0]?.startupUnanswered).toBeUndefined() + } + ) + + it('reports signed-out startup as a notSignedIn fact and proves the process ended', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required')) + }) + const acquired = await rig.acquire({ waitForStart: false }) + await expect( + rig.adapter.open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + ).rejects.toMatchObject({ reason: 'notSignedIn' }) + expect(rig.ended).toEqual([]) + await rig.adapter.closeSession(SESSION) + expect(rig.child().exited).toBe(true) + }) + + it('keeps refused picks in provider-local facts without delaying readiness for another read', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/set_config_option', (frame) => + agent.fail(frame, -32602, 'model unavailable') + ) + }) + await rig.acquire({ options: { model: 'retired-model' } }) + expect(rig.started()).toMatchObject({ + reportedOptions: { model: 'grok-4.7', effort: 'high' } + }) + expect(rig.adapter.readOptionRestoreFailures(SESSION)).toEqual(['model']) + expect(rig.child().exited).toBe(false) + }) + + it('reports the session it loaded as the resumed link', async () => { + const rig = await openAcpAdapterRig({ + launch: { + resume: { + sessionId: 'saved-session', + key: 'saved-key', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } + } + }) + const acquired = await rig.acquire() + expect(acquired.link).toBeUndefined() + expect(rig.started().link).toMatchObject({ + handle: { nativeId: 'saved-session' }, + origin: 'resumed' + }) + expect(rig.sent('session/new')).toEqual([]) + }) +}) 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 400fe7d7561d..b6c895758598 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -5,7 +5,6 @@ import { 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 { agentSessionLaunchFolderMissing } from '../runtime/agent-session-launch-directory' import { tick } from './acp-scripted-agent.test-support' import { @@ -133,7 +132,6 @@ 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 }) @@ -168,7 +166,7 @@ describe('ACP connection loss', () => { rig.child().agent.stdin.destroy() await rig.settle() await waitFor(() => - expect(rig.lifecycle).toMatchObject([ + expect(rig.ended).toMatchObject([ { type: 'ended', cause: 'unexpected-exit', acquisitionGeneration: 'gen-acp' } ]) ) @@ -190,10 +188,10 @@ describe('ACP connection loss', () => { rig.child().stderr = 'panic: out of memory' rig.child().agent.stdout.end() await rig.settle() - expect(rig.lifecycle).toEqual([]) + expect(rig.ended).toEqual([]) rig.child().exit() - await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) - expect(rig.lifecycle[0]).toMatchObject({ + await waitFor(() => expect(rig.ended).toHaveLength(1)) + expect(rig.ended[0]).toMatchObject({ type: 'ended', cause: 'unexpected-exit', reason: 'grok ACP agent exited: panic: out of memory', @@ -209,7 +207,7 @@ describe('ACP connection loss', () => { child.stderr = 'panic: late' child.agent.close() await rig.settle() - expect(rig.lifecycle).toEqual([]) + expect(rig.ended).toEqual([]) // Never left Orca, so it is not recorded as unconfirmed. expect( await rig.adapter.dispatch({ @@ -228,8 +226,8 @@ describe('ACP connection loss', () => { name: 'AgentSessionAcquisitionExitUnprovenError' }) child.exit() - await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) - expect(rig.lifecycle[0]).toMatchObject({ + await waitFor(() => expect(rig.ended).toHaveLength(1)) + expect(rig.ended[0]).toMatchObject({ cause: 'unexpected-exit', reason: 'grok ACP agent exited: panic: late', failure: { kind: 'providerExited', detail: { text: 'panic: late' } } @@ -274,7 +272,7 @@ describe('ACP connection loss', () => { await rig.settle() expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['garbled', 'next']) expect(rig.child().closes).toBe(0) - expect(rig.lifecycle).toEqual([]) + expect(rig.ended).toEqual([]) }) it('leaves Grok running when it ends its stdout but can still be written to', async () => { @@ -288,9 +286,9 @@ describe('ACP connection loss', () => { await rig.settle() expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'running' }) expect(rig.child().closes).toBe(0) - expect(rig.lifecycle).toEqual([]) + expect(rig.ended).toEqual([]) await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) - expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + expect(rig.ended).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) }) }) @@ -334,77 +332,85 @@ describe('ACP refusals', () => { }) describe('ACP startup that never answers', () => { - it('lets a close stop the child while the handshake is unanswered', async () => { + it('lets a close stop the published child while initialize is unanswered', async () => { const rig = await openAcpAdapterRig({ script: (agent) => agent.on('initialize', () => {}) }) - const queue = new StructuredAgentSessionTaskQueue() - const start = new AbortController() - const acquiring = queue.serialize(SESSION, () => rig.acquire({ signal: start.signal })) - const failed = acquiring.catch((error: unknown) => error) + const acquired = await rig.acquire({ waitForStart: false }) + const opening = rig.adapter + .open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + .catch((error: unknown) => error) await rig.frame('initialize') - // What the host's close does outside the queue, then its queued stop. - 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' }) - await expect(closing).resolves.toBe(true) - expect(rig.lifecycle).toEqual([]) + expect(await rig.adapter.closeSession(SESSION)).toBe(true) + expect(await opening).toBeInstanceOf(Error) + expect(rig.child().exited).toBe(true) + expect(rig.ended).toMatchObject([{ cause: 'requested-close' }]) + expect(rig.lifecycle.some((event) => event.type === 'started')).toBe(false) }) - it('leaves the child of a start that returned alone when its signal aborts later', async () => { + it('leaves a published child alone when the acquisition 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([]) + expect(rig.ended).toEqual([]) }) - it('lets a handshake Grok never answers run on until the start is aborted', async () => { + it('does not time out a published handshake, and closes it through the session', async () => { + const rig = await openAcpAdapterRig({ script: (agent) => agent.on('session/new', () => {}) }) + const acquired = await rig.acquire({ waitForStart: false }) + const opening = rig.adapter + .open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + .catch((error: unknown) => error) + await rig.frame('session/new') 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) + await vi.advanceTimersByTimeAsync(30 * 60_000) + expect(rig.ended).toEqual([]) expect(rig.child().closes).toBe(0) - start.abort() - expect(await failed).toBeInstanceOf(Error) - expect(rig.child().closes).toBeGreaterThan(0) + expect(await rig.adapter.closeSession(SESSION)).toBe(true) + expect(await opening).toBeInstanceOf(Error) } 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', () => {}) - }) - const start = new AbortController() - const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error) - await rig.frame('initialize') + it('keeps a failed startup child until its exit is proven, and spawns no other meanwhile', async () => { + const rig = await openAcpAdapterRig({ script: (agent) => agent.on('initialize', () => {}) }) + const acquired = await rig.acquire({ waitForStart: false }) + const opening = rig.adapter + .open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + .catch((error: unknown) => error) + const frame = await rig.frame('initialize') const child = rig.child() 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. + child.agent.fail(frame, -32603, 'provider failed to initialize') + expect(await opening).toBeInstanceOf(Error) + expect(child.closes).toBe(0) + expect(rig.ended).toEqual([]) 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({ + await expect(rig.acquire()).rejects.toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) child.exit() + expect(rig.ended).toMatchObject([{ cause: 'requested-close' }]) expect(await rig.adapter.closeSession(SESSION)).toBe(true) }) @@ -459,7 +465,7 @@ describe('ACP session release', () => { const rig = await openAcpAdapterRig() await expect(rig.adapter.releaseAcquisition({ sessionId: SESSION })).resolves.toBe(true) expect(rig.spawned).toEqual([]) - expect(rig.lifecycle).toEqual([]) + expect(rig.ended).toEqual([]) }) it('keeps nothing of a chat once its child is proven closed', async () => { @@ -476,7 +482,7 @@ describe('ACP session release', () => { const rig = await openAcpAdapterRig() await rig.acquire() rig.child().exit() - await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) + await waitFor(() => expect(rig.ended).toHaveLength(1)) expect(rig.adapter.backgroundTaskStops(SESSION)).toBeUndefined() 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 index 1818beee8456..b263c7ce2e92 100644 --- a/src/main/acp/acp-structured-session-adapter-resume.test.ts +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -7,7 +7,6 @@ import { 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' @@ -296,8 +295,8 @@ describe('reattaching a Grok chat the journal holds', () => { script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Resource not found')) }) - const acquired = await rig.acquire() - expect(acquired.link).toMatchObject({ + await rig.acquire() + expect(rig.started().link).toMatchObject({ origin: 'created', handle: { nativeId: PROVIDER_SESSION }, supersedesKey: 'acp:grok:never-saved' @@ -328,13 +327,13 @@ describe('reattaching a Grok chat the journal holds', () => { initialize: RESUMES, script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, code, message)) }) - const acquired = await rig.acquire() - expect(acquired.link).toMatchObject({ + await rig.acquire() + expect(rig.started().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.started().link?.supersedesKey).toBeUndefined() expect(rig.sent('session/new')).toHaveLength(1) const rows = await notRestoredRows(rig) expect(rows).toHaveLength(1) @@ -359,9 +358,8 @@ describe('reattaching a Grok chat the journal holds', () => { 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' }) + await rig.acquire() + expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' }) expect(rig.sent('session/new')).toEqual([]) }) @@ -381,13 +379,12 @@ describe('reattaching a Grok chat the journal holds', () => { deps: { logger }, script: (agent) => agent.on('session/load', (frame) => { - start.abort() + void rig.adapter.closeSession(SESSION) agent.fail(frame, -32603, 'session file is corrupt') }) }) - expect( - await rig.acquire({ signal: start.signal }).catch((error: unknown) => error) - ).toBeInstanceOf(Error) + await rig.acquire({ signal: start.signal }) + expect(rig.ended).toMatchObject([{ cause: 'requested-close' }]) expect(rig.sent('session/new')).toEqual([]) expect(await notRestoredRows(rig)).toEqual([]) // Nothing was replaced, so nothing says it was. diff --git a/src/main/acp/acp-structured-session-adapter-teardown.test.ts b/src/main/acp/acp-structured-session-adapter-teardown.test.ts index 9d9e3022826e..fab34e911cde 100644 --- a/src/main/acp/acp-structured-session-adapter-teardown.test.ts +++ b/src/main/acp/acp-structured-session-adapter-teardown.test.ts @@ -62,7 +62,7 @@ describe('ACP teardown of a running 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' }]) + expect(rig.ended).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) }) it('a dispose ends the child at the grace when the agent never ends its turn', async () => { diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 291dfe000d61..716c6e65f5d2 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -4,11 +4,7 @@ import { closeProviderTimelineRigs, SESSION } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { - AgentSessionAcquisitionExitProvenError, - AgentSessionAcquisitionRefusal, - AgentSessionAcquisitionRootExitObservedError -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { GENERIC_ACP_DIALECT } from './acp-dialects/acp-dialect' import { STRUCTURED_CHILD_ENV_TO_DELETE } from '../runtime/structured-session-child-env' import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' @@ -52,7 +48,7 @@ describe('ACP structured session adapter: acquire', () => { 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({ + expect(rig.started().link).toMatchObject({ handle: { transport: 'acp', agent: 'grok', nativeId: PROVIDER_SESSION }, origin: 'created', mintedAtFence: 1 @@ -113,8 +109,8 @@ describe('ACP structured session adapter: acquire', () => { 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' }) + await rig.acquire({ fence: 2 }) + expect(rig.started().link).toMatchObject({ origin: 'created', supersedesKey: 'old-key' }) }) it('refuses a signed-out agent through the existing start-failure surface and stops its child', async () => { @@ -122,9 +118,9 @@ describe('ACP structured session adapter: acquire', () => { 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' }) + await rig.acquire() + expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' }) + expect(rig.ended).toMatchObject([{ cause: 'requested-close' }]) expect(rig.child().closes).toBe(1) }) }) @@ -138,10 +134,10 @@ it('reports an agent that exits while starting with its own last words', async ( 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([]) + await rig.acquire() + expect(rig.ended).toMatchObject([ + { failure: { detail: { text: 'grok: config.toml is invalid' } } } + ]) }) it('reads those last words when the agent ends its stdout before its exit is seen', async () => { @@ -155,9 +151,10 @@ it('reads those last words when the agent ends its stdout before its exit is see 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' }) + await rig.acquire() + expect(rig.ended).toMatchObject([ + { failure: { detail: { text: 'grok: config.toml is invalid' } } } + ]) }) describe('ACP structured session adapter: turns', () => { @@ -434,18 +431,18 @@ describe('ACP structured session adapter: close and exit', () => { await expect(rig.adapter.closeSession(SESSION)).rejects.toBeInstanceOf( AgentSessionAcquisitionRootExitObservedError ) - expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + expect(rig.ended).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) }) 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([ + expect(rig.ended).toMatchObject([ { type: 'ended', sessionId: SESSION, cause: 'requested-close', fence: 1 } ]) await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) - expect(rig.lifecycle).toHaveLength(1) + expect(rig.ended).toHaveLength(1) }) it('ends the session on an unexpected exit: held sends rejected, the running one unknown', async () => { @@ -457,7 +454,7 @@ describe('ACP structured session adapter: close and exit', () => { rig.child().stderr = 'grok: crashed' rig.child().exit() await waitFor(() => - expect(rig.lifecycle).toMatchObject([ + expect(rig.ended).toMatchObject([ { type: 'ended', cause: 'unexpected-exit', diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 7388ab04ef6c..3efd9f928508 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -3,14 +3,14 @@ // them; compaction is the agent's own `/compact` prompt, for an agent whose launch spec offers it. import { randomUUID } from 'node:crypto' -import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentSessionFailureFact, withProviderDiagnostic } 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 { AgentSessionAcquisitionExitProvenError, - AgentSessionAcquisitionRootExitObservedError, AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRefusal, AgentSessionPreSpawnError, isAgentSessionPreSpawnError, type AgentSessionAcquisition, @@ -21,19 +21,13 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' 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 { - closeAcpSessionJournal, - endAcpStructuredSession, - type AcpStructuredSession -} from './acp-structured-session' -import { - ProviderAcquisitionStarts, - type ProviderStartAttempt -} from '../provider-process/provider-acquisition-starts' +import { acpAgentName } from './acp-structured-agent-definitions' +import { acquireAcpStructuredSession } from './acp-structured-acquire' +import type { AcpStructuredSession } from './acp-structured-session' +import type { ProviderStartAttempt } from '../provider-process/provider-acquisition-starts' import { waitForAcpExit, type AcpStructuredConnection } from './acp-structured-connection' import { AcpConnectionClosedError } from './acp-errors' -import { awaitAcpTurnEnd, interruptAcpTurn, windDownAcpTurn } from './acp-structured-stop' +import { awaitAcpTurnEnd, interruptAcpTurn } from './acp-structured-stop' import { acpDispatchPrompt } from './acp-prompt-content' import { ACP_OPTION_WRITE_TIMEOUT_MS, @@ -43,13 +37,18 @@ import { import { readAcpLiveOptions, writeAcpSessionOption } from './acp-structured-options' import { readAcpRecoveryHistory } from './acp-recovery-history' import { stopAcpChildren, acpChildStopCapabilities } from './acp-structured-child-stop' +import { + acpStartupFailure, + isAcpReadySession, + type AcpStructuredChild +} from './acp-structured-child' +import { AcpStructuredChildren } from './acp-structured-child-lifecycle' 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() - private readonly starts = new ProviderAcquisitionStarts() - - constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {} + private readonly children: AcpStructuredChildren + constructor(private readonly deps: AcpStructuredSessionAdapterDeps) { + this.children = new AcpStructuredChildren(deps) + } /** Restart recovery's evidence; null for an agent whose own store Orca cannot read. */ providerHistoryWindow: NonNullable = ({ @@ -62,9 +61,9 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte async acquire(input: StructuredAgentSessionAcquireInput): Promise { const sessionId = input.identity.sessionId - const attempt = this.starts.begin(input.signal) + const attempt = this.children.starts.begin(input.signal) try { - if (!(await this.stop(sessionId))) { + if (!(await this.children.stop(sessionId))) { throw new AgentSessionAcquisitionExitUnprovenError( new Error( `the previous ${this.deps.spec.agent} child for ${sessionId} could not be stopped` @@ -73,7 +72,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } return await this.start(input, attempt) } finally { - this.starts.end(attempt) + this.children.starts.end(attempt) } } @@ -89,23 +88,22 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte deps: this.deps, generation, abandoned: () => attempt.signal.aborted, - track: (connection) => this.starts.track(attempt, connection), + track: (connection) => this.children.starts.track(attempt, connection), onExit: (session) => { - if (session && this.sessions.get(sessionId) === session) { - this.finish(session, this.now()) + if (session && this.children.sessions.get(sessionId) === session) { + this.children.finish(session, this.now()) } }, - onConnectionLost: (session, error) => this.connectionLost(session, error), + onConnectionLost: (session, error) => this.children.connectionLost(session, error), onSettled: (settlement) => this.deps.onDispatchSettledLate?.({ sessionId, ...settlement }), forceClose: (id) => void this.forceCloseSession(id) }) if (attempt.signal.aborted) { - session.lane.dispose() + session.dispose() throw new Error('closed while starting') } - this.sessions.set(sessionId, session) - // Its saved picks restored: what it runs now is what its start resolved. - return { ...acquisition, catalogListing: session.options.startListing() } + this.children.sessions.set(sessionId, session) + return acquisition } catch (error) { const { connection } = attempt if (connection && error instanceof AcpConnectionClosedError) { @@ -117,7 +115,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte 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) + this.children.starts.retainFailed(sessionId, connection) throw new AgentSessionAcquisitionExitUnprovenError(error) } if (attempt.signal.aborted) { @@ -139,6 +137,31 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } } + open: NonNullable = async (input) => { + const child = this.children.sessions.get(input.sessionId) + if ( + !child || + child.fence !== input.fence || + child.acquisitionGeneration !== input.acquisitionGeneration || + child.ended + ) { + throw new Error('ACP child changed before it opened') + } + try { + return await child.open(input.signal) + } catch (error) { + const failure = acpStartupFailure(child, error) + if (failure) { + const refusal = new AgentSessionAcquisitionRefusal( + `${acpAgentName(child.spec.agent)} requires sign-in`, + 'notSignedIn' + ) + throw failure.detail ? withProviderDiagnostic(refusal, failure.detail) : refusal + } + throw error + } + } + async dispatch(input: { sessionId: string clientMessageId: string @@ -147,7 +170,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte requestedAt?: number beforeDispatch?: () => Promise }): Promise { - const lost = this.sessions.get(input.sessionId) + const lost = this.children.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') @@ -157,7 +180,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte if (!Array.isArray(prompt)) { return { state: 'rejected', ...prompt } } - if (this.sessions.get(input.sessionId) !== session || session.journalClosed !== null) { + if (this.children.sessions.get(input.sessionId) !== session || session.journalClosed !== null) { // The child ended while its attachments were read: nothing left Orca. return this.rejected(session, 'providerExited') } @@ -192,14 +215,13 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return { cancelled: true } } - // 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. + // Stop is a session boundary: a cancel ends only the running turn, and background work could + // begin another turn. The next send reloads the session. stopEndsSession = (): boolean => true awaitStoppedRequestEnd = async (sessionId: string, stoppedAt: number): Promise => { - const session = this.sessions.get(sessionId) - if (session) { + const session = this.children.sessions.get(sessionId) + if (session && isAcpReadySession(session)) { await awaitAcpTurnEnd(session, stoppedAt, this.stopGraceMs()) } } @@ -228,10 +250,32 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte readOptions = async (input: { sessionId: string; fence: number }) => readAcpLiveOptions(this.live(input.sessionId)) - readOptionRestoreFailures = (sessionId: string): readonly string[] => - this.sessions.get(sessionId)?.restoreSkipped ?? [] + readOptionRestoreFailures = (sessionId: string): readonly string[] => { + const session = this.children.sessions.get(sessionId) + return session && isAcpReadySession(session) ? session.restoreSkipped : [] + } + + readCommands = (sessionId: string) => { + const session = this.children.sessions.get(sessionId) + return session && isAcpReadySession(session) ? session.options.readCommands() : undefined + } + + holdsDispatch = (sessionId: string): boolean => { + const session = this.children.sessions.get(sessionId) + return session ? isAcpReadySession(session) && session.turns.running : false + } + + holdsLiveProviderProcess = (sessionId: string, generation: string): boolean => { + const session = this.children.sessions.get(sessionId) + return session?.acquisitionGeneration === generation && !session.connection.exited + } - readCommands = (sessionId: string) => this.sessions.get(sessionId)?.options.readCommands() + acknowledgeSessionRelease = (sessionId: string): void => { + const session = this.children.sessions.get(sessionId) + if (session?.ended) { + this.children.sessions.delete(sessionId) + } + } stopBackgroundTasks: NonNullable = ( input @@ -239,99 +283,30 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte backgroundTaskStops: NonNullable = ( sessionId - ) => acpChildStopCapabilities(this.sessions.get(sessionId)) + ) => acpChildStopCapabilities(this.children.sessions.get(sessionId)) - closeSession = (sessionId: string): Promise => this.close(sessionId) - disposeSession = (sessionId: string): Promise => this.close(sessionId) - releaseAcquisition = (input: { sessionId: string }) => this.close(input.sessionId) + closeSession = (sessionId: string): Promise => this.children.close(sessionId) + disposeSession = (sessionId: string): Promise => this.children.close(sessionId) + releaseAcquisition = (input: { sessionId: string }) => this.children.close(input.sessionId) /** After a sink failure: the exit is recovered as unexpected. */ - forceCloseSession = (sessionId: string): Promise => this.stop(sessionId, false) + forceCloseSession = (sessionId: string): Promise => this.children.stop(sessionId, false) async closeAll(): Promise { - const ids = new Set([...this.sessions.keys(), ...this.starts.failedSessionIds()]) + const ids = new Set([ + ...this.children.sessions.keys(), + ...this.children.starts.failedSessionIds() + ]) // 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))) + const proven = await Promise.all( + [...ids].map((sessionId) => this.children.stop(sessionId, true)) + ) if (proven.includes(false)) { throw new Error('an ACP agent child could not be proven stopped') } } - /** 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 connection = this.sessions.get(sessionId)?.connection - const closed = await this.stop(sessionId, true) - if (closed && connection?.processTreeUnproven) { - 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: - * 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) - ]) - return failedStart && session - } - - private async stopSession(sessionId: string, requested: boolean): Promise { - const session = this.sessions.get(sessionId) - 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 - session.lane.flush() - } - const proven = await session.connection.close() - if (proven) { - 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 rejected( - session: AcpStructuredSession, + session: AcpStructuredChild, kind: 'providerExited' ): AgentSessionDispatchOutcome { return { @@ -344,8 +319,8 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } private live(sessionId: string): AcpStructuredSession { - const session = this.sessions.get(sessionId) - if (!session || session.journalClosed !== null) { + const session = this.children.sessions.get(sessionId) + if (!session || !isAcpReadySession(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 88242d5a7c0b..c78b9b7581fd 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -15,6 +15,7 @@ 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' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' export type AcpStructuredSession = { sessionId: string @@ -28,7 +29,6 @@ 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 @@ -38,6 +38,8 @@ export type AcpStructuredSession = { ended: boolean exitObservedAt: number | null unbindReadingControl?: () => void + dispose: () => void + open: (signal: AbortSignal) => Promise } /** Every agent frame goes through here once the lane exists: options and commands first, so a diff --git a/src/main/acp/acp-structured-sign-in.test.ts b/src/main/acp/acp-structured-sign-in.test.ts index f7349b78470c..1be21fab0b06 100644 --- a/src/main/acp/acp-structured-sign-in.test.ts +++ b/src/main/acp/acp-structured-sign-in.test.ts @@ -1,9 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { z } from 'zod' -import { providerDiagnosticOf } from '../../shared/agent-session-failure' -import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { agentSessionFailureSentence } from '../../shared/agent-session-failure-words' -import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { ACP_LAUNCH_SPECS } from './acp-launch-specs' import { @@ -84,16 +82,35 @@ it.each(ACP_LAUNCH_SPECS)( spec, script: (agent) => agent.on('session/new', (frame) => agent.fail(frame, -32000, detail)) }) - const error = await rig.acquire().catch((failure: unknown) => failure) - expect(error).toBeInstanceOf(AgentSessionAcquisitionRefusal) - expect(error).toMatchObject({ reason: 'notSignedIn' }) - expect(providerDiagnosticOf(error)).toEqual({ text: detail, audience: 'person' }) - const fact = providerStartupFailureFact(error) - expect(fact.detail?.text).toBe(detail) + // The host receives the refusal from open and then closes the child. + await rig.acquire() + expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' }) + expect(providerDiagnosticOf(rig.openFailure())).toEqual({ text: detail, audience: 'person' }) + const fact = agentSessionFailureFact('notSignedIn', { + detail: providerDiagnosticOf(rig.openFailure()) + }) expect(agentSessionFailureSentence(fact, 'row', { agentName: spec.agent })).toContain(detail) } ) +it.each(ACP_LAUNCH_SPECS)( + "keeps $agent's sign-in words over log lines it wrote before refusing its start", + async (spec) => { + const detail = 'Sign-in credentials are missing.' + const rig = await openAcpAdapterRig({ + spec, + script: (agent) => + agent.on('session/new', (frame) => { + rig.child().stderr = 'warn: telemetry endpoint unreachable' + agent.fail(frame, -32000, detail) + }) + }) + await rig.acquire() + expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' }) + expect(providerDiagnosticOf(rig.openFailure())).toEqual({ text: detail, audience: 'person' }) + } +) + it.each([ [ 'No API key found for anthropic.\n\nUse /login, set an API key environment variable, or create /host/agent.db', diff --git a/src/main/acp/acp-structured-steer.test.ts b/src/main/acp/acp-structured-steer.test.ts index 421ecd31f641..bcd27f99c0c3 100644 --- a/src/main/acp/acp-structured-steer.test.ts +++ b/src/main/acp/acp-structured-steer.test.ts @@ -166,7 +166,7 @@ describe('a send while a Grok prompt runs', () => { await new Promise((resolve) => setTimeout(resolve, 120)) await rig.settle() expect(rig.sent('session/cancel')).toHaveLength(1) - expect(rig.lifecycle).toEqual([]) + expect(rig.ended).toEqual([]) expect(rig.child().closes).toBe(0) expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['first']) // A Stop sends its own cancel; the host's grace, not the cancel, ends Grok. @@ -223,7 +223,7 @@ describe('a send while a turn Grok began itself runs', () => { // `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([]) + expect(rig.ended).toEqual([]) expect(rig.child().closes).toBe(0) agent.reply(a, { stopReason: 'end_turn' }) const b = await rig.frame('session/prompt', 1) diff --git a/src/main/acp/acp-turn-failures.ts b/src/main/acp/acp-turn-failures.ts index ba87ec6a5050..ee6a4f92d1d1 100644 --- a/src/main/acp/acp-turn-failures.ts +++ b/src/main/acp/acp-turn-failures.ts @@ -1,14 +1,13 @@ import { agentSessionFailureFact, providerDiagnostic, - withProviderDiagnostic + type SubmissionRejectionFact } 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' import { AcpAgentError, AcpAuthRequiredError } from './acp-errors' -import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' /** Ends the provider failed, rather than ones it chose (a refusal, a token limit). */ const FAILED_STOP_REASONS = ['error', 'rate_limit'] @@ -41,18 +40,13 @@ export function acpAuthenticationRequired(dialect: AcpDialect, error: unknown): ) } -export function acpSignInRequiredRefusal( - agent: string, +/** A sign-in refusal in the agent's own words. */ +export function acpSignInRequiredFailure( dialect: AcpDialect, error: AcpAgentError -): AgentSessionAcquisitionRefusal { - return withProviderDiagnostic( - new AgentSessionAcquisitionRefusal( - `${agent} reported that it is not signed in: ${error.message}`, - 'notSignedIn' - ), - providerDiagnostic(acpPromptErrorDetail(dialect, error), 'person') - ) +): SubmissionRejectionFact { + const detail = providerDiagnostic(acpPromptErrorDetail(dialect, error), 'person') + return agentSessionFailureFact('notSignedIn', detail ? { detail } : {}) } /** One error row per failed turn, in the provider's own words, as a Codex turn-ending error reads: diff --git a/src/main/claude/claude-structured-applied-options.test.ts b/src/main/claude/claude-structured-applied-options.test.ts index f4043c1fd489..8f5d8379e657 100644 --- a/src/main/claude/claude-structured-applied-options.test.ts +++ b/src/main/claude/claude-structured-applied-options.test.ts @@ -5,7 +5,8 @@ import { acquired, adapterFor, fakeClaude, - identityFor + identityFor, + claudeOpened } from './claude-structured-session-test-support' /** Verbatim rows from Claude Code 2.1.280's list_models: `default` is the CLI's own @@ -134,12 +135,10 @@ describe('Claude model before the first turn', () => { const events: ClaudeStructuredSessionEvent[] = [] const adapter = await acquired(startedWithoutATurn(SETTINGS.noOverride), {}, events) - const started = events.find((event) => event.type === 'started') + const started = await claudeOpened(adapter, 'session-1') expect(started).toMatchObject({ reportedOptions: { model: 'opus[1m]' } }) // Saved, it would be restored on every reopen past a later settings.json change. - expect(started?.type === 'started' ? started.reportedOptions : null).not.toHaveProperty( - 'effort' - ) + expect(started.reportedOptions).not.toHaveProperty('effort') const reported = events.find((event) => event.type === 'options-reported') expect( reported?.type === 'options-reported' ? reported.reportedOptions : null @@ -174,8 +173,8 @@ describe('Claude model before the first turn', () => { }) // `started` is persisted as the session's options; opus's effort must not ride along onto haiku. - const started = events.find((event) => event.type === 'started') + const started = await claudeOpened(adapter, 'session-1') expect(started).toMatchObject({ reportedOptions: { model: 'haiku' } }) - expect(started?.type === 'started' ? started.reportedOptions.effort : null).toBeUndefined() + expect(started.reportedOptions.effort).toBeUndefined() }) }) diff --git a/src/main/claude/claude-structured-dispatch-test-support.ts b/src/main/claude/claude-structured-dispatch-test-support.ts index 80a774e648bc..4b9718ae45fe 100644 --- a/src/main/claude/claude-structured-dispatch-test-support.ts +++ b/src/main/claude/claude-structured-dispatch-test-support.ts @@ -5,7 +5,8 @@ import type { ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' -import { createClaudeSessionStartup } from './claude-structured-session-startup-state' +import { createClaudeInitProof } from './claude-structured-init-proof' +import { claudeProviderHandleLink } from './claude-structured-owner-identity' export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): ClaudeSession { return { @@ -34,7 +35,19 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C capabilities: [], events: undefined, translator: null, - startup: { ...createClaudeSessionStartup(), state: 'proven' } + link: claudeProviderHandleLink({ + sessionId: 'provider-session', + leafUuid: null, + resumed: false, + fence: 1, + observedAt: 1 + }), + initialization: { + proof: createClaudeInitProof(), + account: undefined, + resumesTranscript: false, + onLateRefusal: () => {} + } } } diff --git a/src/main/claude/claude-structured-dispatch-waiters.ts b/src/main/claude/claude-structured-dispatch-waiters.ts index 2c5df3c671eb..061c8da5454a 100644 --- a/src/main/claude/claude-structured-dispatch-waiters.ts +++ b/src/main/claude/claude-structured-dispatch-waiters.ts @@ -15,7 +15,7 @@ export function forgetRetiredWaiter(session: ClaudeSession, waiter: ClaudeDispat * starts — an interval bounded only by the previous turn. Elapsed time is * therefore not evidence about delivery, and nothing here expires. Waiters end * by provider and process facts instead: an echo, a `command_lifecycle` frame, - * the CLI's idle after `started` (`claude-command-lifecycle.ts`), a failed + * the CLI's idle after open (`claude-command-lifecycle.ts`), a failed * write, or child exit. */ export function waitForReplay( diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 87e706ddbbc3..3467a474e0bf 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -28,10 +28,6 @@ import { claudeUserMessageWasProvablyUnwritten } from './claude-agent-sdk-user-message-queue' import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement' -import { - claudeStartupFailureFact, - failClaudeStartup -} from './claude-structured-session-startup-state' const MAX_ACTIVE_DISPATCH_WAITERS = 64 @@ -99,7 +95,6 @@ export function releaseClaudeDispatchWaitersInDoubt( * CLI's idle has not. Retired rather than dropped: their identities stay joinable, bounded by * `MAX_RETIRED_DISPATCH_WAITERS`. */ export function retireClaudeDispatchWaiters(session: ClaudeSession): void { - failClaudeStartup(session, new Error('claude stream-json ended before startup completed')) for (const waiter of session.dispatchWaiters.splice(0)) { retireWaiter(session, waiter) waiter.resolve(null) @@ -132,10 +127,6 @@ export async function dispatchClaudeTurn( if (session.dispatchWaiters.length >= MAX_ACTIVE_DISPATCH_WAITERS) { return { state: 'rejected', ...claudeDispatchRejection(agentSessionFailureFact('queueFull')) } } - const startupFailure = claudeStartupFailureFact(session) - if (startupFailure) { - return { state: 'rejected', ...claudeDispatchRejection(startupFailure) } - } // Read the sent content, not the journal blocks: only the mapped trailing prompt decides // whether Claude runs a command, so the two cannot disagree about which frame settles this. const acceptsResult = claudeDispatchInvokesSlashCommand(content) 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 72eb85f677c5..7cc0a917b4aa 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 @@ -27,7 +27,8 @@ import { fakeClaude, identityFor, recordingJournalSink, - claudeStartupSettled + claudeStartupSettled, + claudeOpened } from './claude-structured-session-test-support' const SESSION = 'session-1' @@ -396,14 +397,13 @@ describe('Claude effort default at rest', () => { it("never saves the applied effort as the chat's pick, yet shows it at rest", async () => { const store = new AgentModelCatalogStore() const events: ClaudeStructuredSessionEvent[] = [] - await startChild(store, undefined, events) + const adapter = await startChild(store, undefined, events) - const started = events.find((event) => event.type === 'started') - const reported = started?.type === 'started' ? started.reportedOptions : null + const reported = (await claudeOpened(adapter, SESSION)).reportedOptions expect(reported).not.toHaveProperty('effort') // The record the start persists names the listed row the catalog learned under. const record = restingRecord( - nativeSessionOptionsFromReport({ reported: reported!, restoreSkipped: [] }) + nativeSessionOptionsFromReport({ reported: reported, restoreSkipped: [] }) ) expect(record.options).toEqual({ model: 'opus[1m]' }) expect((await readAtRest(store, record)).current.effort).toBe('medium') diff --git a/src/main/claude/claude-structured-event-delivery.ts b/src/main/claude/claude-structured-event-delivery.ts index 9e543a37a658..5430eb82f8c1 100644 --- a/src/main/claude/claude-structured-event-delivery.ts +++ b/src/main/claude/claude-structured-event-delivery.ts @@ -45,13 +45,6 @@ export function emitClaudeStructuredSessionEvent({ const retired = event.type === 'message' && session ? retireClaudeLaunchedModel(session, event.message) : null if (session && retired !== null) { - deps.onEvent?.({ - type: 'options-skipped', - sessionId: event.sessionId, - fence: session.fence, - acquisitionGeneration: session.acquisitionGeneration, - options: { model: retired } - }) resetClaudeRetiredModel(session, deps, event.sessionId, retired) } publishChildWork(event.sessionId, session, event.type === 'message' ? event.message : null) diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index 2843d4f7ddd7..64ada44671d7 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -5,7 +5,8 @@ import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-req import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' -import { createClaudeSessionStartup } from './claude-structured-session-startup-state' +import { createClaudeInitProof } from './claude-structured-init-proof' +import { claudeProviderHandleLink } from './claude-structured-owner-identity' import { observeClaudeFastModeFacts, readClaudeStructuredSessionOptions @@ -43,7 +44,19 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe capabilities: [], events: undefined, translator: null, - startup: { ...createClaudeSessionStartup(), state: 'proven' } + link: claudeProviderHandleLink({ + sessionId: 'provider-session', + leafUuid: null, + resumed: false, + fence: 1, + observedAt: 1 + }), + initialization: { + proof: createClaudeInitProof(), + account: undefined, + resumesTranscript: false, + onLateRefusal: () => {} + } } } diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index 6aa1ae8cc0e7..042c513f5972 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -47,15 +47,6 @@ export function setClaudeStructuredSessionOption( input: { key: string; value: string }, timeoutMs: number | undefined ): Promise>> { - // Each write is a control request the CLI answers only after initialize. - if (session.startup.state !== 'proven') { - return Promise.reject( - new AgentSessionOptionRejectedError( - 'Claude is still starting; options can be changed once it is ready.', - 'providerStarting' - ) - ) - } return setClaudeStructuredOption(session, input, timeoutMs) } diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index a7d1c41c8bfe..7214ead80a8d 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -65,7 +65,7 @@ export async function cancelClaudeStructuredTurn(input: { const acquisitionGeneration = session.acquisitionGeneration // A CLI still starting answers no interrupt: Stop ends its child next (`stopEndsSession`), and // that end settles every message it was handed and never echoed as stopped. - if (request.prompt || session.startup.state === 'pending') { + if (request.prompt) { return { cancelled: false } } const requestedTurnId = request.turnId diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index 7d2b564716b0..66b996819014 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -25,7 +25,7 @@ import { type ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state' -import { claudeStartupSettled } from './claude-structured-session-test-support' +import { claudeOpened, claudeStartupSettled } from './claude-structured-session-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const command = realClaudeCommand @@ -167,18 +167,21 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { const adapter = realAdapter(providerSessionId, claudeConfigDir, events, cwd) try { - const acquisition = await adapter.acquire({ + await adapter.acquire({ identity: identity(providerSessionId), fence: 1, spawnToken: 'real-cli' }) + await claudeStartupSettled(adapter, 'real-cli-handshake') const observedSubtypes = events.flatMap((event) => event.type === 'message' ? [event.message.subtype] : [] ) // 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((await claudeOpened(adapter, 'real-cli-handshake')).link?.handle).toEqual( + claudeProviderHandle(providerSessionId, null) + ) expect(observedSubtypes).toContain('hook_started') expect(adapter.readCommands('real-cli-handshake')).toContainEqual( expect.objectContaining({ @@ -216,6 +219,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1, spawnToken: 'real-cli-effort' }) + await claudeStartupSettled(adapter, 'real-cli-handshake') const published = events.flatMap((event) => event.type === 'message' ? [event.message] : [] ) @@ -260,6 +264,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1, spawnToken: 'real-cli-transcript' }) + await claudeStartupSettled(adapter, 'real-cli-handshake') await adapter.dispatch({ sessionId: 'real-cli-handshake', clientMessageId: 'real-cli-transcript-1', @@ -303,6 +308,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1, spawnToken: 'real-cli-model' }) + await claudeStartupSettled(adapter, 'real-cli-handshake') await adapter.setOption({ sessionId: 'real-cli-handshake', key: 'model', @@ -349,11 +355,11 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { 90_000 ) - // The host hands a Claude child nothing until `started`, which now follows the initialize answer + // The host hands a Claude child nothing until open, which follows the initialize answer // alone. Measures what that wait costs against the live binary, so the startup limits are tuned // from data: spawn to `started` with nothing sent, then a send to its first output. it.skipIf(!realClaudeAuthenticated)( - 'reports `started` with nothing sent, then answers a send handed over after it', + 'opens with nothing sent, then answers a send handed over after it', async () => { const providerSessionId = randomUUID() const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') @@ -381,10 +387,9 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1, spawnToken: 'real-cli-started-timing' }) + await claudeStartupSettled(adapter, 'real-cli-handshake') const publishedAt = Date.now() - expect(await until(() => events.some((event) => event.type === 'started'), 30_000)).toBe( - true - ) + await claudeStartupSettled(adapter, 'real-cli-handshake') const startedAt = Date.now() const before = events.length await adapter.dispatch({ @@ -400,7 +405,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { // Reported for tuning; the assertions above are the contract. console.info( `[real-cli startup timing] acquire->published ${publishedAt - acquireAt} ms, ` + - `published->started ${startedAt - publishedAt} ms, ` + + `published->open ${startedAt - publishedAt} ms, ` + `dispatch->first output ${firstOutputAt - startedAt} ms` ) } finally { @@ -411,13 +416,12 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { ) // The contract a chat's first message depends on: saved options and the chat's permission mode - // ride the launch, and the message is written as soon as the child is published, before the CLI - // answers initialize. + // ride the launch, and the message is written after open answers initialize. it.skipIf(!realClaudeAuthenticated).each([ ['accept-edits', 'acceptEdits'], ['full-access', 'bypassPermissions'] ] as const)( - 'runs a message written before initialize answers, launched with saved options and %s', + 'runs a message after open with saved options and %s', async (permissionMode, cliPermissionMode) => { const providerSessionId = randomUUID() const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') @@ -442,6 +446,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { options: { model: 'sonnet', effort: 'low' }, permissionMode }) + await claudeStartupSettled(adapter, 'real-cli-handshake') const dispatched = await adapter.dispatch({ sessionId: 'real-cli-handshake', clientMessageId: 'real-cli-saved-options-1', @@ -453,11 +458,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1 }) // Read as the write resolves: the CLI had not answered initialize, so nothing held it. - const answeredAtDispatch = adapter['sessions'].get('real-cli-handshake')?.startup.answered - const startedAtDispatch = events.some((event) => event.type === 'started') expect(dispatched).toEqual({ state: 'admitted' }) - expect(answeredAtDispatch).toBe(false) - expect(startedAtDispatch).toBe(false) const deadline = Date.now() + 90_000 while (!messages().some((m) => m.type === 'result') && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 250)) @@ -522,6 +523,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1, spawnToken: 'real-cli-stop' }) + await claudeStartupSettled(adapter, 'real-cli-handshake') const first = events.length await expect( send('real-cli-stop-1', 'Count from 1 to 300, one number per line, and nothing else.') @@ -593,6 +595,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1, spawnToken: 'real-cli-queued-stop' }) + await claudeStartupSettled(adapter, 'real-cli-handshake') await send('real-cli-queued-stop-a', 'Count from 1 to 400, one number per line.') // A's reply is streaming, so the next send queues behind its turn. await expect( @@ -656,6 +659,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1, spawnToken: 'real-cli-no-hooks' }) + await claudeStartupSettled(adapter, 'real-cli-handshake') expect(frames().filter((frame) => readClaudeInit(frame) !== null)).toEqual([]) await expect( adapter.dispatch({ @@ -691,7 +695,8 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { events, process.cwd(), undefined, - {} + {}, + false ) try { @@ -700,11 +705,9 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1, spawnToken: 'real-cli-no-auth' }) - await adapter.drainObservedExits() - expect(events.find((event) => event.type === 'ended')).toMatchObject({ - reason: expect.stringMatching(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s), - startupUnproven: true - }) + await expect(claudeStartupSettled(adapter, 'real-cli-handshake')).rejects.toThrow( + /not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s + ) } finally { await adapter.closeAll() await rm(claudeConfigDir, { recursive: true, force: true }) diff --git a/src/main/claude/claude-structured-resume-point.test.ts b/src/main/claude/claude-structured-resume-point.test.ts index 9465feba5d3d..bc7ba2badc2c 100644 --- a/src/main/claude/claude-structured-resume-point.test.ts +++ b/src/main/claude/claude-structured-resume-point.test.ts @@ -6,7 +6,8 @@ import { identityFor, PROVIDER_SESSION_ID, recordingJournalSink, - tick + tick, + claudeOpened } from './claude-structured-session-test-support' /** A resumed owner that completed one turn (`a4`) and then saw the next turn's prompt. */ @@ -82,13 +83,15 @@ describe('Claude resume point is the last completed turn on every exit path', () [], persisted ) - const acquisition = await adapter.acquire({ + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-7', events: recordingJournalSink() }) - expect(acquisition.link.handle).toMatchObject({ resumeCursor: 'a3' }) + expect((await claudeOpened(adapter, 'session-1')).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-retired-model.ts b/src/main/claude/claude-structured-retired-model.ts index b6433d7a060a..3b233201722b 100644 --- a/src/main/claude/claude-structured-retired-model.ts +++ b/src/main/claude/claude-structured-retired-model.ts @@ -60,13 +60,3 @@ export function resetClaudeRetiredModel( }) ) } - -/** The saved values this child showed it cannot run. Only a retired launch model is one: a launch - * never skips a model, so a skipped model is that. */ -export function claudeRetiredOptions( - session: Pick -): { retiredOptions?: Record } { - return session.restoreSkippedOptions.has('model') && session.launchedModel !== null - ? { retiredOptions: { model: session.launchedModel } } - : {} -} diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 1cf0ded65df2..bba3eb1a1ee3 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -18,12 +18,7 @@ import { claudeProcessIdentity } from './claude-structured-owner-identity' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { adoptClaudeStructuredSpawnOptions } from './claude-structured-spawn-options' import { observeClaudeFastModeFacts } from './claude-structured-session-options' -import { - readClaudeStartupFacts, - settleClaudeSessionStartup -} from './claude-structured-session-startup' import { createClaudeSessionPublication } from './claude-structured-session-publication' -import { readClaudeStructuredSessionSettings } from './claude-structured-session-acquisition-options' import { mintClaudeAcquisitionGeneration, type ClaudeAcquisitionRegistry, @@ -243,6 +238,12 @@ export async function acquireClaudeSession({ process, acquisitionGeneration: mintClaudeAcquisitionGeneration(deps), options: launch.savedOptions.options, + initialization: { + proof: initProof, + account: launch.account, + resumesTranscript: launch.resumesTranscript, + onLateRefusal: (error) => callbacks.handleExit(sessionId, attempt, error) + }, ...(deps.mintLinkId ? { linkId: deps.mintLinkId() } : {}), observedAt: deps.now?.() ?? Date.now() }) @@ -257,37 +258,6 @@ export async function acquireClaudeSession({ event() } }) - // Whichever comes first: the start landing or faulting, or the child being ended. - session.startup.settled = Promise.race([ - session.startup.settled, - settleClaudeSessionStartup({ - session, - facts: readClaudeStartupFacts({ - account: launch.account, - connection, - initProof, - sessionId, - providerSessionId: launch.providerSessionId, - startup: session.startup, - resumesTranscript: launch.resumesTranscript, - requestTimeoutMs: deps.requestTimeoutMs, - emit - }), - readSettings: () => readClaudeStructuredSessionSettings(connection, deps.requestTimeoutMs), - isCurrent: () => sessions.get(sessionId) === session, - fault: (error) => callbacks.handleExit(sessionId, attempt, error), - diagnose: (diagnostic) => emit({ type: 'auth-diagnostic', sessionId, diagnostic }), - // The host's minted attempt always carries it; only adapter tests that pass less omit it. - optionRevision: () => input.optionRevision?.() ?? 0, - report: (event) => - emit({ - ...event, - sessionId, - fence: input.fence, - acquisitionGeneration: session.acquisitionGeneration - }) - }) - ]) // A child whose exit already reached `handleExit` is not handed over as live: the create // fails with the CLI's own diagnostic, as one that died before publish does. if (sessions.get(sessionId) !== session) { @@ -295,7 +265,7 @@ export async function acquireClaudeSession({ exits.get(sessionId)?.error ?? new Error('claude session ended before acquisition returned') ) } - // The start reads its facts only after publish, so the child is `starting` until `started`. + // The host calls open after publication and holds input until it settles. return { ...publication.acquisition, providerChildPhase: 'starting' } } catch (error) { unbindReadingControl?.() diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 40d3f71982e9..87c04b453ec7 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -2,7 +2,6 @@ import { homedir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { - AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRootExitObservedError, AgentSessionPromptAnswerRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -19,12 +18,14 @@ import type { import { acquired, adapterFor, + adapterAtPublishFor, fakeClaude, identityFor, PROVIDER_SESSION_ID, tick, USER_MESSAGE, - type FakeConnection + type FakeConnection, + claudeOpened } from './claude-structured-session-test-support' import { invokeCanUseTool } from './claude-can-use-tool-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' @@ -59,7 +60,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { processStartTimeMs: 1_700_000_000_000, spawnToken: 'spawn-9' }) - expect(acquisition.link).toEqual({ + expect((await claudeOpened(adapter, 'session-1')).link).toEqual({ linkId: `claude-7-${PROVIDER_SESSION_ID}-empty`, handle: claudeProviderHandle(PROVIDER_SESSION_ID, null), origin: 'created', @@ -162,9 +163,8 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { subtype: 'apply_flag_settings', params: { settings: { fastMode: true } } }) - expect(events.find((event) => event.type === 'started')).toMatchObject({ - reportedOptions: { fastMode: true }, - restoreSkippedOptions: [] + expect(await claudeOpened(adapter, 'session-1')).toMatchObject({ + reportedOptions: { fastMode: true } }) }) @@ -194,19 +194,13 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { // Started, with the pick, while the apply is still unanswered: nothing waits on it. expect(claude.connections[0].calls.at(-1)?.subtype).toBe('apply_flag_settings') - expect(events.find((event) => event.type === 'started')).toMatchObject({ - reportedOptions: { fastMode: true }, - restoreSkippedOptions: [] + expect(await claudeOpened(adapter, 'session-1')).toMatchObject({ + reportedOptions: { fastMode: true } }) answerApply(failure) await tick() - // A refusal drops the pick, as main's refused restore did, from the record too, after - // `started`; silence keeps it wanted and unconfirmed. - const skipped = events.filter((event) => event.type === 'options-skipped') - expect(skipped).toEqual( - kept ? [] : [expect.objectContaining({ fence: 7, options: { fastMode: 'true' } })] - ) + // A refusal drops the child's value; silence keeps it wanted and unconfirmed. expect(adapter.readOptionRestoreFailures('session-1')).toEqual(kept ? [] : ['fastMode']) const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) expect(options.current.fastMode === true).toBe(kept) @@ -435,13 +429,15 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { const events: ClaudeStructuredSessionEvent[] = [] const adapter = adapterFor(claude, {}, events) - const acquisition = await adapter.acquire({ + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - expect(acquisition.link.handle).toEqual(claudeProviderHandle(PROVIDER_SESSION_ID, null)) + expect((await claudeOpened(adapter, 'session-1')).link?.handle).toEqual( + claudeProviderHandle(PROVIDER_SESSION_ID, null) + ) expect(events[0]).toMatchObject({ type: 'message', message: { subtype: 'hook_started', hook_name: 'SessionStart:startup' } @@ -483,43 +479,30 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { continuesChain: true, resumeLeafUuid: 'leaf-before' }) - const acquisition = await resumed.acquire({ + await resumed.acquire({ identity: identityFor(), fence: 9, spawnToken: 'spawn-9' }) - expect(acquisition.link.origin).toBe('resumed') - expect(acquisition.link.handle).toEqual( + expect((await claudeOpened(resumed, 'session-1')).link?.origin).toBe('resumed') + expect((await claudeOpened(resumed, 'session-1')).link?.handle).toEqual( claudeProviderHandle(PROVIDER_SESSION_ID, 'leaf-before') ) const wrongClaude = fakeClaude({ initSessionId: 'different-session' }) - await expect(endedAtStartup(wrongClaude)).resolves.toMatchObject({ - reason: expect.stringMatching(/expected/), - startupUnproven: true - }) + const wrong = adapterAtPublishFor(wrongClaude) + await wrong.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await expect(claudeOpened(wrong, 'session-1')).rejects.toThrow('provider session expected') + await wrong.closeSession('session-1') expect(wrongClaude.connections[0].closeCount).toBe(1) }) - it('fails the acquire with the CLI diagnostic when the exit lands before the handover', async () => { - // No init delay: the child dies inside the initialize call, before acquire can return it. + it('reports the CLI diagnostic when the child exits during open', async () => { const claude = fakeClaude({ exitBeforeInit: 'Claude login required' }) - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = adapterFor(claude, {}, events) - - await expect( - adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - ).rejects.toThrow('Claude login required') + const adapter = adapterAtPublishFor(claude) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await expect(claudeOpened(adapter, 'session-1')).rejects.toThrow('Claude login required') await adapter.drainObservedExits() - - // The published-then-ended path is the slow-init case in the startup suite. The first-hand - // exit is still reported as it was seen; the host holds no session under it to end. - expect(events.find((event) => event.type === 'ended')).toMatchObject({ - reason: 'Claude login required', - cause: 'unexpected-exit', - startupUnproven: true - }) - expect(claude.connections[0].closeCount).toBeGreaterThanOrEqual(1) }) it('refuses an unauthenticated initialize response even when SessionStart runs', async () => { @@ -527,70 +510,15 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { initProof: 'session-start', initAccount: { apiProvider: 'firstParty', tokenSource: 'none' } }) - - await expect(endedAtStartup(claude)).resolves.toMatchObject({ - reason: expect.stringMatching(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s), - startupUnproven: true - }) + const adapter = adapterAtPublishFor(claude) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await expect(claudeOpened(adapter, 'session-1')).rejects.toThrow('not signed in') + await adapter.closeSession('session-1') expect(claude.connections[0].closeCount).toBe(1) }) }) -/** Acquires, then returns the `ended` event the startup failure published. */ -async function endedAtStartup( - claude: ReturnType, - input: Parameters[0] = { - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9' - }, - launch: Parameters[1] = {} -): Promise { - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = adapterFor(claude, launch, events) - await adapter.acquire(input) - await adapter.drainObservedExits() - return events.find((event) => event.type === 'ended') -} - describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { - /** A start that fails after the child self-exited, with its close verdict scripted. */ - function failedStart( - unprovenCloseVerdict: ClaudeStreamJsonConnection['exitVerdict'] - ): Promise { - const claude = fakeClaude({ - exitBeforeInit: 'claude stream-json exited (code 1): not logged in', - unprovenCloseVerdict - }) - return adapterFor(claude, { - resumesTranscript: true, - continuesChain: true, - resumeLeafUuid: 'tip' - }) - .acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - .catch((error: unknown) => error) - } - - // The root's pid and start time are the lease's identity, and they are provably dead: latching - // the session would strand a signed-out user. The lease follows the root, so a descendant seen - // alive does not hold the session either. - it.each(['unverifiable', 'live'] as const)( - 'releases on a first-hand root exit with its tree %s while still carrying the CLI diagnostic', - async (tree) => { - const error = await failedStart({ root: 'exited', tree }) - - expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) - expect((error as Error).message).toBe('claude stream-json exited (code 1): not logged in') - } - ) - - it('never releases for a root Orca never saw leave', async () => { - const error = await failedStart({ root: 'live', tree: 'unverifiable' }) - - expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) - expect(error).not.toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) - }) - /** A published session whose CLI then exits first-hand, with the verdict its ladder holds. */ async function exitedAfterPublish( exitVerdict: ClaudeStreamJsonConnection['exitVerdict'] diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index bbc1d6fea64e..d4c9689d863d 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -1,4 +1,5 @@ import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import type { AgentSessionAcquisition, @@ -13,8 +14,6 @@ import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' import { setClaudeStructuredSessionOption } from './claude-structured-options' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' -import { claudeStartupSettledWithin } from './claude-structured-session-startup-state' -import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' import { ClaudeAcquisitionRegistry, type ClaudeAcquisitionAttempt, @@ -46,6 +45,7 @@ import { settleClaudePromptFreeingChild } from './claude-structured-prompt-ownership' import { claudePromptCancelRoute } from './claude-structured-prompt-replies' +import { openClaudeStructuredSession } from './claude-structured-session-open' export type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' export type { @@ -233,13 +233,27 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda input, this.deps.requestTimeoutMs ) - startAnswered = (sessionId: string): boolean | undefined => - this.sessions.get(sessionId)?.startup.answered - awaitOptionWritable = (sessionId: string): Promise => - claudeStartupSettledWithin( - this.sessions.get(sessionId), - this.deps.requestTimeoutMs ?? CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS - ) + open: NonNullable = async (request) => { + try { + return await openClaudeStructuredSession({ + request, + session: this.session(request.sessionId), + sessions: this.sessions, + deps: this.deps, + emit: (session, event) => this.emit(session, event) + }) + } catch (error) { + const exit = this.exits.get(request.sessionId) + // Publish an already-observed exit before the host treats this rejection as its own fault. + if ( + exit?.session.acquisitionGeneration === request.acquisitionGeneration && + exit.publication + ) { + await waitForPromiseWithSignal(exit.publication, request.signal) + } + throw error + } + } readOptions = (input: { sessionId: string; fence: number }) => readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) readOptionRestoreFailures = (sessionId: string): readonly string[] => [ diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index f6b4b0a8c9ef..316d90b0a509 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -147,8 +147,7 @@ async function finalizeClaudePublishedSession( cause: 'requested-close', fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, - observedAt: Date.now(), - ...(session.startup.answered ? {} : { startupUnanswered: true as const }) + observedAt: Date.now() } try { try { diff --git a/src/main/claude/claude-structured-session-exit-lifecycle.ts b/src/main/claude/claude-structured-session-exit-lifecycle.ts index 80bd6f5f6d62..3936ed89cb19 100644 --- a/src/main/claude/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/claude-structured-session-exit-lifecycle.ts @@ -1,17 +1,10 @@ import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' -import { - providerExitObserved, - providerStartupFailureFact -} from '../native-chat/agent-session-wire/structured-agent-session-failure-text' +import { providerExitObserved } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { settledClaudeTurnEndLeaf } from './claude-structured-resume-point' import { claudeRootExitObserved, settleClaudeExitedSession } from './claude-structured-session-close' -import { - claudeStartupFailureCause, - failClaudeStartup -} from './claude-structured-session-startup-state' import type { ClaudeAcquisitionAttempt, ClaudeSession, @@ -40,9 +33,6 @@ export function observeClaudeSessionExit( return } lifecycle.sessions.delete(sessionId) - // Not marked here: startup and journal faults end the session this way too. The child's own - // exit arrives already marked by the connection's exit callback. - failClaudeStartup(session, error) // Re-enter the provider's close ladder before publishing lifecycle recovery. // An exit callback is root evidence only; the retained tree proof must run // before the host releases and reacquires this exact child. @@ -97,20 +87,13 @@ export function settleClaudeUnexpectedExit( reason: exit.error.message, // A start that never landed says why it failed. After it landed, only the child's own exit // blames the provider; an Orca fault that closed it is Orca's. - failure: - exit.session.startup.state !== 'proven' - ? providerStartupFailureFact(claudeStartupFailureCause(exit.session, exit.error)) - : providerExitObserved(exit.error) - ? agentSessionFailureFact('providerExited', { - detail: providerDiagnosticOf(exit.error) - }) - : agentSessionFailureFact('hostFault'), + failure: providerExitObserved(exit.error) + ? agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(exit.error) }) + : agentSessionFailureFact('hostFault'), cause: 'unexpected-exit', fence: exit.session.fence, acquisitionGeneration: exit.session.acquisitionGeneration, - observedAt: deps.now?.() ?? Date.now(), - ...(exit.session.startup.state === 'proven' ? {} : { startupUnproven: true }), - ...(exit.session.startup.answered ? {} : { startupUnanswered: true }) + observedAt: deps.now?.() ?? Date.now() } try { lifecycle.emit(exit.session, ended) diff --git a/src/main/claude/claude-structured-session-open.ts b/src/main/claude/claude-structured-session-open.ts new file mode 100644 index 000000000000..f1870a7600c5 --- /dev/null +++ b/src/main/claude/claude-structured-session-open.ts @@ -0,0 +1,62 @@ +import type { + StructuredAgentSessionProviderOpenInput, + StructuredAgentSessionProviderOpenResult +} from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { readClaudeStructuredSessionSettings } from './claude-structured-session-acquisition-options' +import { openClaudeSession, readClaudeStartupFacts } from './claude-structured-session-startup' +import type { + ClaudeSession, + ClaudeStructuredSessionAdapterDeps, + ClaudeStructuredSessionEvent +} from './claude-structured-session-state' + +/** The host awaits this under its own start signal after the process is published. */ +export async function openClaudeStructuredSession(input: { + request: StructuredAgentSessionProviderOpenInput + session: ClaudeSession + sessions: Map + deps: ClaudeStructuredSessionAdapterDeps + emit: (session: ClaudeSession, event: ClaudeStructuredSessionEvent) => void +}): Promise { + const { request, session, sessions, deps, emit } = input + request.signal.throwIfAborted() + if ( + session.fence !== request.fence || + session.acquisitionGeneration !== request.acquisitionGeneration + ) { + throw new Error('claude session was replaced before open') + } + const { proof, account, resumesTranscript, onLateRefusal } = session.initialization + const opened = openClaudeSession({ + session, + facts: readClaudeStartupFacts({ + connection: session.connection, + initProof: proof, + sessionId: request.sessionId, + providerSessionId: session.providerSessionId, + resumesTranscript, + account, + requestTimeoutMs: deps.requestTimeoutMs, + emit: (event) => emit(session, event) + }), + readSettings: () => + readClaudeStructuredSessionSettings(session.connection, deps.requestTimeoutMs), + isCurrent: () => sessions.get(request.sessionId) === session, + diagnose: (diagnostic) => + emit(session, { type: 'auth-diagnostic', sessionId: request.sessionId, diagnostic }), + onLateRefusal, + report: (event) => + emit(session, { + ...event, + sessionId: request.sessionId, + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration + }) + }) + const result = await waitForPromiseWithSignal(opened, request.signal) + if (sessions.get(request.sessionId) !== session || request.signal.aborted) { + throw new Error('claude session closed before startup completed') + } + return { link: session.link, ...result } +} diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 7276f0eb48c3..2550b6bab18b 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -218,14 +218,10 @@ export async function readClaudeStructuredSessionOptions( timeoutMs: number | undefined ): Promise { const readMutationSequence = session.optionMutationSequence - // Before startup both requests would wait on initialize; answer from the saved options. - const [catalog, settings] = - session.startup.state === 'proven' - ? await Promise.all([ - session.connection.supportedModels({ timeoutMs }).catch(() => null), - session.connection.getSettings({ timeoutMs }).catch(() => null) - ]) - : [null, null] + const [catalog, settings] = await Promise.all([ + session.connection.supportedModels({ timeoutMs }).catch(() => null), + session.connection.getSettings({ timeoutMs }).catch(() => null) + ]) observeClaudeSettingsReadback(session, settings, readMutationSequence) return claudeStructuredSessionOptionsFrom(session, catalog, readMutationSequence) } @@ -270,10 +266,8 @@ export function claudeResolvedCatalogListing( return listing ? { catalogListing: listing } : {} } -/** The options as main already holds them, over `catalog`; asks the CLI nothing. Startup's - * settings readback is applied by the time a start proves, and the SDK answers `list_models` - * from its initialize result, so a started session's snapshot passes that result here rather - * than paying two round trips for what it already read. */ +/** The options already held in memory, over `catalog`; asks the CLI nothing. The SDK answers + * `list_models` from initialize, so open passes that result without another round trip. */ export function claudeStructuredSessionOptionsFrom( session: ClaudeSession, catalog: unknown[] | null, diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index 8c3b524086b4..5630e0f95986 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -6,7 +6,6 @@ import type { ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' -import { createClaudeSessionStartup } from './claude-structured-session-startup-state' /** The session as published at spawn: nothing the CLI reports at init is assumed yet. */ export function createClaudeSessionPublication(input: { @@ -27,18 +26,19 @@ export function createClaudeSessionPublication(input: { linkId?: string observedAt: number options?: ReadonlyMap + initialization: ClaudeSession['initialization'] }): { acquisition: AgentSessionAcquisition; session: ClaudeSession } { + const link = claudeProviderHandleLink({ + sessionId: input.providerSessionId, + leafUuid: input.leafUuid, + resumed: input.continuesChain, + fence: input.fence, + ...(input.linkId ? { linkId: input.linkId } : {}), + observedAt: input.observedAt + }) return { acquisition: { process: input.process, - link: claudeProviderHandleLink({ - sessionId: input.providerSessionId, - leafUuid: input.leafUuid, - resumed: input.continuesChain, - fence: input.fence, - ...(input.linkId ? { linkId: input.linkId } : {}), - observedAt: input.observedAt - }), acquisitionGeneration: input.acquisitionGeneration }, session: { @@ -69,7 +69,8 @@ export function createClaudeSessionPublication(input: { translator: input.translator, events: input.events, ...(input.unbindReadingControl ? { unbindReadingControl: input.unbindReadingControl } : {}), - startup: createClaudeSessionStartup() + link, + initialization: input.initialization } } } diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index fbee25044669..92c3f52ec0f7 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -11,7 +11,8 @@ import { identityFor, PROVIDER_SESSION_ID, tick, - claudeStartupSettled + claudeStartupSettled, + claudeOpened } from './claude-structured-session-test-support' import { invokeCanUseTool } from './claude-can-use-tool-test-support' import { @@ -387,7 +388,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { expect(oldPrompt.settled()).toBe(false) expect(events.filter((event) => event.type === 'ended')).toEqual([]) - const replacement = await adapter.acquire({ + await adapter.acquire({ identity: { ...identityFor(), providerHandle: claudeProviderHandle(PROVIDER_SESSION_ID, 'observed-retained-leaf') @@ -396,6 +397,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { spawnToken: 'spawn-10', events: journalSink }) + const replacement = await claudeOpened(adapter, 'session-1') expect(disposeTranslator).toHaveBeenCalledOnce() expect(settlePrompt).toHaveBeenCalledOnce() diff --git a/src/main/claude/claude-structured-session-startup-state.ts b/src/main/claude/claude-structured-session-startup-state.ts deleted file mode 100644 index 2ece1890fe82..000000000000 --- a/src/main/claude/claude-structured-session-startup-state.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Where a Claude start stands. A session is published once its child is spawned, launched with the -// chat's saved options, before the CLI has answered initialize. The host hands it no message until -// `started`; an option write, a control request initialize must answer first, waits here too. - -import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' -import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' -import type { ClaudeSession } from './claude-structured-session-state' -import { withMissingProviderExecutable } from '../provider-process/provider-executable-missing' - -export type ClaudeSessionStartup = { - state: 'pending' | 'proven' | 'failed' - /** The CLI answered initialize: it may have run what it was handed. Before that it ran nothing. */ - answered: boolean - failure: Error | null - /** Resolves once startup has landed or faulted, or the child exited or was closed; never - * rejects. A close must end it: an option write waits here. */ - settled: Promise - end: () => void -} - -export function createClaudeSessionStartup(): ClaudeSessionStartup { - let end: () => void = () => undefined - const ended = new Promise((resolve) => { - end = resolve - }) - return { state: 'pending', answered: false, failure: null, settled: ended, end } -} - -/** Why a start failed; a CLI that was never found says so, whichever error the start met first. */ -export function claudeStartupFailureCause( - session: ClaudeSession, - fallback?: Error -): Error | undefined { - const failure = session.startup.failure ?? fallback - return failure && session.connection.executableMissing - ? withMissingProviderExecutable(failure) - : failure -} - -export function claudeStartupFailureFact(session: ClaudeSession): SubmissionRejectionFact | null { - return session.startup.state === 'failed' - ? providerStartupFailureFact(claudeStartupFailureCause(session)) - : null -} - -/** Resolves when startup lands or `timeoutMs` passes; a stuck start then refuses the write as before. */ -export function claudeStartupSettledWithin( - session: ClaudeSession | undefined, - timeoutMs: number -): Promise { - if (session?.startup.state !== 'pending') { - return Promise.resolve() - } - let timer: ReturnType | undefined - return Promise.race([ - session.startup.settled, - new Promise((resolve) => { - timer = setTimeout(resolve, timeoutMs) - }) - ]).finally(() => clearTimeout(timer)) -} - -/** Startup cannot land any more: the child exited, was closed, or its start faulted. */ -export function failClaudeStartup(session: ClaudeSession, error: Error): void { - const startup = session.startup - if (startup.state === 'pending') { - startup.state = 'failed' - startup.failure = error - } - startup.end() -} diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index 7eece9a4ed6d..42cc302773cd 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -1,444 +1,98 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-adapter' -import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' import { adapterAtPublishFor, fakeClaude, identityFor, - USER_MESSAGE, - claudeStartupSettled + claudeOpened } from './claude-structured-session-test-support' -import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' - -/** Far past any deadline a start used to have. */ -const NEVER_MS = 10 * 60_000 - -type LateSettlement = Parameters< - NonNullable ->[0] +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' -const SLOW_INIT_MS = 12_000 +const ACQUIRE = { identity: identityFor(), fence: 7, spawnToken: 'spawn-9' } -function startingAdapter(claude: ReturnType): { - adapter: ReturnType - events: ClaudeStructuredSessionEvent[] - late: LateSettlement[] -} { +function adapterFor(claude: ReturnType) { const events: ClaudeStructuredSessionEvent[] = [] - const late: LateSettlement[] = [] - const adapter = adapterAtPublishFor( - claude, - {}, - events, - [], - undefined, - undefined, - undefined, - (settlement) => late.push(settlement) - ) - return { adapter, events, late } + return { adapter: adapterAtPublishFor(claude, {}, events), events } } -const ACQUIRE = { identity: identityFor(), fence: 7, spawnToken: 'spawn-9' } -const PROMPT = { sessionId: 'session-1', clientMessageId: 'client-1', body: USER_MESSAGE, fence: 7 } - -describe('Claude structured session publishes before the CLI answers initialize', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - afterEach(() => { - vi.useRealTimers() - }) - - it('creates a session whose init takes longer than any old deadline, then reports its facts', async () => { - const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) - const { adapter, events } = startingAdapter(claude) - - await expect(adapter.acquire(ACQUIRE)).resolves.toBeDefined() +describe('Claude host-awaited open', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('publishes the owned process as starting before initialize answers', async () => { + const claude = fakeClaude({ initDelayMs: 12_000 }) + const { adapter, events } = adapterFor(claude) + const acquisition = await adapter.acquire(ACQUIRE) + expect(acquisition).toMatchObject({ providerChildPhase: 'starting', process: { pid: 4321 } }) + expect(acquisition.link).toBeUndefined() + expect(claude.connections[0].calls).toEqual([]) expect(events.some((event) => event.type === 'options')).toBe(false) - expect(adapter.readCommands('session-1')).toBeUndefined() - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await claudeStartupSettled(adapter, 'session-1') - expect(events.find((event) => event.type === 'options')).toMatchObject({ - models: [{ value: 'claude-sonnet' }] + const open = claudeOpened(adapter, 'session-1') + await vi.advanceTimersByTimeAsync(11_999) + expect(claude.connections[0].calls.map((call) => call.subtype)).toEqual(['initialize']) + await vi.advanceTimersByTimeAsync(1) + await expect(open).resolves.toMatchObject({ + link: expect.objectContaining({ handle: expect.objectContaining({ agent: 'claude' }) }), + reportedOptions: expect.any(Object) }) - expect(events.some((event) => event.type === 'ended')).toBe(false) - expect(claude.connections[0].closeCount).toBe(0) + expect(events.map((event) => event.type)).not.toContain('started') await adapter.closeAll() }) - it('reports `started` on the initialize answer, never waiting on the settings read', async () => { - const SLOW_SETTINGS_MS = 25_000 - const claude = fakeClaude({ - initDelayMs: SLOW_INIT_MS, - // The fake answers `get_settings` with whatever this resolves to, when it resolves. - settings: new Promise((resolve) => - setTimeout( - () => resolve({ effective: { fastMode: true }, applied: { model: 'claude-haiku-4-5' } }), - SLOW_INIT_MS + SLOW_SETTINGS_MS - ) - ) + it('returns from open without waiting for optional settings', async () => { + let answerSettings: (value: unknown) => void = () => {} + const settings = new Promise((resolve) => { + answerSettings = resolve }) - const { adapter, events } = startingAdapter(claude) + const claude = fakeClaude({ settings }) + const { adapter, events } = adapterFor(claude) await adapter.acquire(ACQUIRE) - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - + const opened = await claudeOpened(adapter, 'session-1') + expect(opened.reportedOptions).toBeDefined() expect(claude.connections[0].calls.map((call) => call.subtype)).toContain('get_settings') - expect(events.filter((event) => event.type === 'started')).toHaveLength(1) expect(events.some((event) => event.type === 'options-reported')).toBe(false) - expect(events.some((event) => event.type === 'auth-diagnostic')).toBe(false) - - await vi.advanceTimersByTimeAsync(SLOW_SETTINGS_MS) - await claudeStartupSettled(adapter, 'session-1') - - expect(events.find((event) => event.type === 'options-reported')).toMatchObject({ - reportedOptions: { model: 'claude-haiku-4-5', fastMode: true } - }) - expect(events.some((event) => event.type === 'auth-diagnostic')).toBe(true) - await adapter.closeAll() - }) - - it('reports `started` with the options its child was launched with, having written none', async () => { - const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS, initModel: 'claude-opus-9' }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire({ ...ACQUIRE, options: { model: 'opus' } }) - expect(claude.connections[0].launch.options.model).toBe('opus') - expect(events.some((event) => event.type === 'started')).toBe(false) - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await claudeStartupSettled(adapter, 'session-1') - - const startedAt = events.findIndex((event) => event.type === 'started') - expect(events[startedAt]).toEqual({ - type: 'started', - sessionId: 'session-1', - fence: 7, - acquisitionGeneration: expect.any(String), - // What the child was launched with, carried so the host never asks the CLI again. - reportedOptions: expect.objectContaining({ model: 'opus' }), - restoreSkippedOptions: [], - // The account listing initialize answered, for the host's catalog. - catalogListing: { models: [expect.objectContaining({ id: 'claude-sonnet' })] }, - // No host revision handed in: nothing has moved it. - optionRevision: 0 - }) - expect(claude.connections[0].calls.map(({ subtype }) => subtype)).toEqual([ - 'initialize', - 'get_settings' - ]) - expect(claude.connections[0].sent).toEqual([]) - expect(events.slice(0, startedAt).some((event) => event.type === 'options')).toBe(true) - await adapter.closeAll() - }) - - it('lets an option write wait for startup instead of refusing it', async () => { - const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) - const { adapter } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - const pick = { sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 } - await expect(adapter.setOption(pick)).rejects.toThrow('still starting') - - let writable = false - const waited = adapter.awaitOptionWritable('session-1').then(() => { - writable = true - }) - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS - 1) - expect(writable).toBe(false) - await vi.advanceTimersByTimeAsync(1) - await waited - - await expect(adapter.setOption(pick)).resolves.toMatchObject({ model: 'opus' }) + answerSettings({ applied: { model: 'claude-haiku-4-5' }, effective: {} }) + await vi.waitFor(() => + expect(events.find((event) => event.type === 'options-reported')).toMatchObject({ + reportedOptions: { model: 'claude-haiku-4-5' } + }) + ) await adapter.closeAll() }) - it('stops waiting on a start that never lands, so the write is refused as before', async () => { - const claude = fakeClaude({ initDelayMs: 10 * CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS }) - const { adapter } = startingAdapter(claude) + it('rejects an advertised signed-out initialize answer for the host to close once', async () => { + const claude = fakeClaude({ initAccount: { tokenSource: 'none', apiKeySource: 'none' } }) + const { adapter } = adapterFor(claude) await adapter.acquire(ACQUIRE) - let writable = false - void adapter.awaitOptionWritable('session-1').then(() => { - writable = true - }) - await vi.advanceTimersByTimeAsync(CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS) - expect(writable).toBe(true) - await expect( - adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 }) - ).rejects.toThrow('still starting') - await adapter.closeAll() - }) - - // The child was launched with the chat's saved options, so nothing waits on initialize: the SDK - // streams the message to the CLI, which takes it behind its own start. - it('writes a message sent before initialize answers at once', async () => { - const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire({ ...ACQUIRE, options: { model: 'opus' } }) - - await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) - expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) - expect(events.some((event) => event.type === 'started')).toBe(false) - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await claudeStartupSettled(adapter, 'session-1') - expect(events.some((event) => event.type === 'started')).toBe(true) - await adapter.closeAll() - }) - - it('confirms a launched effort only when the settings readback reports that effort', async () => { - const reportsHigh = { - applied: { model: 'claude-sonnet-5', effort: 'high' }, - effective: { model: 'claude-sonnet-5', effortLevel: 'high', env: {} }, - sources: {} - } - const agreeing = fakeClaude({ settings: reportsHigh }) - const disagreeing = fakeClaude({ settings: reportsHigh }) - const started = async (claude: ReturnType, effort: string) => { - const { adapter } = startingAdapter(claude) - await adapter.acquire({ ...ACQUIRE, options: { effort } }) - await claudeStartupSettled(adapter, 'session-1') - return adapter - } - - const kept = await started(agreeing, 'high') - await expect(kept.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ - current: { effort: 'high', confirmed: expect.arrayContaining(['effort']) } - }) - // The saved pick stays wanted; only the readback could vouch for it, and it did not. - const unconfirmed = await started(disagreeing, 'low') - const options = await unconfirmed.readOptions({ sessionId: 'session-1', fence: 7 }) - expect(options.current.effort).toBe('low') - expect(options.current.confirmed ?? []).not.toContain('effort') - await kept.closeAll() - await unconfirmed.closeAll() - }) - - // Measured on Claude Code 2.1.280: `--effort xhigh` reads back as `applied.effort: 'xhigh'` with - // `effective` empty, since `effective` holds only the settings files. - it('confirms a launched effort the CLI reports only as applied', async () => { - const claude = fakeClaude({ - settings: { applied: { model: 'claude-opus-5', effort: 'xhigh' }, effective: {}, sources: {} } - }) - const { adapter } = startingAdapter(claude) - await adapter.acquire({ ...ACQUIRE, options: { effort: 'xhigh' } }) - await claudeStartupSettled(adapter, 'session-1') - - const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) - expect(options.current).toMatchObject({ effort: 'xhigh', confirmed: ['effort'] }) - await adapter.closeAll() - }) - - it('ends the session with the exit reason when the CLI dies before init', async () => { - const claude = fakeClaude({ - initDelayMs: SLOW_INIT_MS, - exitBeforeInit: 'claude stream-json exited (code 1): stderr says no' - }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await claudeStartupSettled(adapter, 'session-1') - await adapter.drainObservedExits() - - expect(events.find((event) => event.type === 'ended')).toMatchObject({ - reason: 'claude stream-json exited (code 1): stderr says no', - cause: 'unexpected-exit', - startupUnproven: true - }) - expect(claude.connections[0].sent).toEqual([]) - expect(claude.connections[0].closeCount).toBe(1) - }) - - it('ends a start whose root exit was seen first-hand even when its descendants are unverifiable', async () => { - const claude = fakeClaude({ - initDelayMs: SLOW_INIT_MS, - exitBeforeInit: 'claude stream-json exited (code 1): stderr says no', - unprovenCloseVerdict: { root: 'exited', tree: 'unverifiable' } - }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await claudeStartupSettled(adapter, 'session-1') - await adapter.drainObservedExits() - - // A failed start is released on the same evidence a failed create is. - expect(events.find((event) => event.type === 'ended')).toMatchObject({ - cause: 'unexpected-exit', - startupUnproven: true - }) - }) - - it('lands a start whose CLI answers initialize only after minutes', async () => { - const claude = fakeClaude({ initDelayMs: NEVER_MS - 1 }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - - await vi.advanceTimersByTimeAsync(NEVER_MS) - await claudeStartupSettled(adapter, 'session-1') - - expect(events.some((event) => event.type === 'started')).toBe(true) - expect(events.some((event) => event.type === 'ended')).toBe(false) - expect(claude.connections[0].closeCount).toBe(0) - await adapter.closeAll() - }) - - // No deadline ends it: a Stop or a close does, and every message is already written meanwhile. - it('keeps a start whose CLI never answers initialize, taking messages, until it is closed', async () => { - const claude = fakeClaude({ initDelayMs: 10 * NEVER_MS }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) - - await vi.advanceTimersByTimeAsync(NEVER_MS) - - expect(events.some((event) => event.type === 'ended' || event.type === 'started')).toBe(false) + await expect(claudeOpened(adapter, 'session-1')).rejects.toThrow('not signed in') expect(claude.connections[0].closeCount).toBe(0) - expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) - await expect(adapter.closeSession('session-1')).resolves.toBe(true) + await adapter.closeSession('session-1') expect(claude.connections[0].closeCount).toBe(1) }) - it('fails a start at once when a frame names another session before initialize answers', async () => { - const claude = fakeClaude({ initDelayMs: NEVER_MS, initProof: 'none' }) - const { adapter, events } = startingAdapter(claude) + it('rejects a frame that proves a different provider session', async () => { + const claude = fakeClaude({ initSessionId: 'foreign-session' }) + const { adapter } = adapterFor(claude) await adapter.acquire(ACQUIRE) - - claude.connections[0].handlers.onMessage?.({ - type: 'system', - subtype: 'hook_started', - hook_name: 'SessionStart:startup', - session_id: 'foreign-session' - }) - await vi.advanceTimersByTimeAsync(0) - await claudeStartupSettled(adapter, 'session-1') - await adapter.drainObservedExits() - - expect(events.find((event) => event.type === 'ended')).toMatchObject({ - reason: 'claude provider session expected', - failure: { kind: 'startFailed' }, - startupUnproven: true - }) - }) - - // Only a SessionStart hook sends a start frame before the first turn; without one, the - // initialize answer is the whole start. - it('lands a start whose CLI sends no start frame before its first turn', async () => { - const claude = fakeClaude({ initProof: 'none' }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - await claudeStartupSettled(adapter, 'session-1') - - expect(events.some((event) => event.type === 'started')).toBe(true) - await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) - expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) - await adapter.closeAll() - }) - - it('ends a started session whose first frame names another provider session', async () => { - const claude = fakeClaude({ initProof: 'none' }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - await claudeStartupSettled(adapter, 'session-1') - - claude.connections[0].handlers.onMessage?.({ - type: 'system', - subtype: 'init', - session_id: 'foreign-session' - }) - await adapter.drainObservedExits() - - expect(events.find((event) => event.type === 'ended')).toMatchObject({ - reason: 'claude provider session expected', - cause: 'unexpected-exit' - }) + await expect(claudeOpened(adapter, 'session-1')).rejects.toThrow('provider session expected') + await adapter.closeSession('session-1') expect(claude.connections[0].closeCount).toBe(1) }) - it('ends an unauthenticated start with sign-in guidance', async () => { - const claude = fakeClaude({ initAccount: { apiProvider: 'firstParty', tokenSource: 'none' } }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - await claudeStartupSettled(adapter, 'session-1') - await adapter.drainObservedExits() - - expect(events.find((event) => event.type === 'ended')).toMatchObject({ - reason: expect.stringMatching(/not signed in/), - startupUnproven: true - }) - }) - - // Accounts as Claude 2.1.280 reports them at initialize; the /login key row is from its source. - it.each([ - ['an ANTHROPIC_API_KEY', { tokenSource: 'none', apiKeySource: 'ANTHROPIC_API_KEY' }], - ['a Console /login key', { tokenSource: 'none', apiKeySource: '/login managed key' }], - ['an apiKeyHelper', { tokenSource: 'apiKeyHelper', apiKeySource: 'apiKeyHelper' }], - ['an ANTHROPIC_AUTH_TOKEN', { tokenSource: 'ANTHROPIC_AUTH_TOKEN' }], - ['a third-party provider', { apiProvider: 'bedrock' }] - ])('starts a session Claude authenticates with %s', async (_label, account) => { - const claude = fakeClaude({ initAccount: { apiProvider: 'firstParty', ...account } }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - await claudeStartupSettled(adapter, 'session-1') - - expect(events.some((event) => event.type === 'started')).toBe(true) - expect(events.some((event) => event.type === 'ended')).toBe(false) - await adapter.closeAll() - }) - - it('still refuses a start whose API key source is reported as none', async () => { - const claude = fakeClaude({ - initAccount: { apiProvider: 'firstParty', tokenSource: 'none', apiKeySource: 'none' } - }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - await claudeStartupSettled(adapter, 'session-1') - await adapter.drainObservedExits() - - expect(events.find((event) => event.type === 'ended')).toMatchObject({ - reason: expect.stringMatching(/not signed in/) - }) - }) - - // A Stop that closes a child still starting must end the wait an option write is in, though - // initialize never answers. - it('ends the wait on a start closed before init, without faulting it', async () => { - const claude = fakeClaude({ initDelayMs: 10 * SLOW_INIT_MS }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - let ended = false - const waited = claudeStartupSettled(adapter, 'session-1').then(() => { - ended = true - }) - - await expect(adapter.closeSession('session-1')).resolves.toBe(true) - await vi.advanceTimersByTimeAsync(0) - await waited - - expect(ended).toBe(true) - const connection = claude.connections[0] - expect(connection.closeCount).toBe(1) - expect(connection.sent).toEqual([]) - expect(connection.calls.map(({ subtype }) => subtype)).not.toContain('get_settings') - expect(events.some((event) => event.type === 'ended' && event.startupUnproven)).toBe(false) - expect(events.some((event) => event.type === 'started')).toBe(false) - }) - - // The CLI answers no control request before initialize; Claude's Stop ends the child instead. - it('sends no interrupt to a CLI still starting, though it was handed a message', async () => { - const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) - const { adapter } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - await adapter.dispatch(PROMPT) - - await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ - cancelled: false + it('lets the host abort a silent initialize wait and close the owned process', async () => { + const claude = fakeClaude({ initDelayMs: 12_000 }) + const { adapter } = adapterFor(claude) + const acquisition = await adapter.acquire(ACQUIRE) + const controller = new AbortController() + const open = adapter.open({ + sessionId: 'session-1', + fence: 7, + acquisitionGeneration: acquisition.acquisitionGeneration ?? null, + signal: controller.signal }) - - expect(claude.connections[0].calls.map(({ subtype }) => subtype)).not.toContain('interrupt') - expect(adapter.stopEndsSession()).toBe(true) - await adapter.closeAll() + controller.abort(new Error('host start expired')) + await expect(open).rejects.toThrow('host start expired') + await adapter.closeSession('session-1') + expect(claude.connections[0].closeCount).toBe(1) }) }) diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index 492b5a769fb1..f924756e6221 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -1,14 +1,8 @@ -// What Claude reports at initialize, read once the session is published. The initialize answer is -// the start: `started` follows it, and the host hands the child nothing before. The settings read -// after it is optional and never delays that; what it reports is applied as a later options update. -// Every way the start can fail (exit, auth, a foreign session id) faults the published session -// through its exit path; one that stops making progress is ended by the host's startup limit. +// Claude initialize is awaited by the host after process publication. Settings enrichment follows +// independently; the host owns a failed open and its single close. import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' -import type { - StructuredAgentSessionOptionsSkippedEvent, - StructuredAgentSessionStartedEvent -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' import { @@ -23,7 +17,6 @@ import { claudeStructuredSessionPublicationOptions, prepareClaudeStructuredSessionAcquisitionOptions } from './claude-structured-session-acquisition-options' -import { claudeRetiredOptions } from './claude-structured-retired-model' import { claudeResolvedCatalogListing, claudeStructuredSessionOptionsFrom, @@ -31,10 +24,6 @@ import { readClaudeFastModeFacts, readClaudeSettingsEffort } from './claude-structured-session-options' -import { - failClaudeStartup, - type ClaudeSessionStartup -} from './claude-structured-session-startup-state' import type { ClaudeAuthDiagnostic, ClaudeSession, @@ -45,21 +34,15 @@ import { applyClaudeStartFastMode } from './claude-structured-start-fast-mode' -export type StructuredAgentSessionStartedOptions = Pick< - StructuredAgentSessionStartedEvent, - | 'reportedOptions' - | 'restoreSkippedOptions' - | 'retiredOptions' - | 'optionRevision' - | 'catalogListing' +export type ClaudeOpenFacts = Pick< + StructuredAgentSessionProviderOpenResult, + 'reportedOptions' | 'catalogListing' > /** A lifecycle event the start reports, before the session's identity is stamped on it. */ -export type ClaudeStartupReport = - | ({ - type: 'started' | 'options-reported' - } & StructuredAgentSessionStartedOptions) - | Pick +export type ClaudeStartupReport = { + type: 'options-reported' +} & ClaudeOpenFacts /** What the initialize answer proved: enough to start. */ export type ClaudeInitializeFacts = { @@ -82,7 +65,6 @@ export async function readClaudeStartupFacts(input: { initProof: ClaudeInitProof sessionId: string providerSessionId: string - startup: Pick resumesTranscript: boolean account?: AgentSessionAccountKind requestTimeoutMs: number | undefined @@ -90,14 +72,20 @@ export async function readClaudeStartupFacts(input: { }): Promise { // The CLI's first answer has no request deadline of its own; the reads after it do. const initialization = await Promise.race([ - input.connection.initializationResult().then((result) => { - input.startup.answered = true - const authError = claudeInitializationAuthError(result, input.account) - if (authError) { - throw authError - } - return result - }), + input.connection + .initializationResult() + .catch((error: unknown) => { + // The SDK may replace the child's own exit diagnostic with a generic closed-query error. + input.initProof.seen() + throw error + }) + .then((result) => { + const authError = claudeInitializationAuthError(result, input.account) + if (authError) { + throw authError + } + return result + }), input.initProof.promise.then(() => new Promise(() => {})) ]) if (input.connection.closed) { @@ -135,7 +123,7 @@ function applyClaudeInitializeFacts(session: ClaudeSession, facts: ClaudeInitial session.events?.publish() } -/** Settings read after `started`. Values a write made since the read began own are left alone. */ +/** A later settings read cannot overwrite a newer option write. */ function applyClaudeSettingsFacts( session: ClaudeSession, facts: ClaudeStartupFacts, @@ -182,7 +170,7 @@ function claudeStartedReportedOptions( session: ClaudeSession, catalog: unknown[], readMutationSequence = session.optionMutationSequence -): Pick { +): Pick { const { current, catalogListing } = claudeStructuredSessionOptionsFrom( session, catalog, @@ -196,83 +184,44 @@ function claudeStartedReportedOptions( return { reportedOptions: persisted, ...listing } } -/** Applies the initialize answer and reports `started`, so the host hands the child what it holds; - * then reads the optional settings and reports what they add. A failure before `started` faults - * the session so the user sees why it never started; the settings read can fail nothing. */ -export async function settleClaudeSessionStartup(input: { +/** The host awaits initialize and proof while owning the published child. */ +export async function openClaudeSession(input: { session: ClaudeSession facts: Promise - /** Never rejects: an unreadable settings answer reads as null. */ readSettings: () => Promise isCurrent: () => boolean - fault: (error: Error) => void diagnose: (diagnostic: ClaudeAuthDiagnostic) => void - /** The host's option revision now, stamped on each report as its read begins. */ - optionRevision: () => number - /** `started` once startup has proven, with what the child now reports, snapshotted from memory; - * then what the settings add, and any saved option the child showed it cannot run. */ + onLateRefusal: (error: Error) => void report: (event: ClaudeStartupReport) => void -}): Promise { +}): Promise { const { session } = input - const superseded = (): boolean => { + const initialized = await input.facts + if (!input.isCurrent()) { + throw new Error('claude session closed before startup completed') + } + initialized.initProof.onRefusal = (error) => { if (input.isCurrent()) { - return false + input.onLateRefusal(error) } - failClaudeStartup(session, new Error('claude session closed before startup completed')) - return true } - let initialized: ClaudeInitializeFacts - try { - initialized = await input.facts - if (superseded()) { - return - } - // From here no read of the proof is pending, so a frame naming another session ends it. - initialized.initProof.onRefusal = (error) => { - failClaudeStartup(session, error) - if (input.isCurrent()) { - input.fault(error) - } - } - applyClaudeInitializeFacts(session, initialized) - input.report({ - type: 'started', - // `list_models` is answered from this same initialize result, so nothing is re-read. A - // saved Fast the launch left out is decided once settings are read, so this read settles - // none of it (an older sequence never rewrites an option). - ...claudeStartedReportedOptions( - session, - readClaudeModels(initialized.initialization), - session.fastModeAtStart ? session.optionMutationSequence - 1 : undefined - ), - restoreSkippedOptions: [...session.restoreSkippedOptions], - ...claudeRetiredOptions(session), - optionRevision: input.optionRevision() - }) - if (session.startup.state === 'pending') { - session.startup.state = 'proven' - } - } catch (caught) { - const error = caught instanceof Error ? caught : new Error(String(caught)) - // A close or exit that already ended startup owns how the session ends. - const endedElsewhere = session.startup.state !== 'pending' - failClaudeStartup(session, error) - if (!endedElsewhere && input.isCurrent()) { - input.fault(error) - } - return + applyClaudeInitializeFacts(session, initialized) + const result: ClaudeOpenFacts = { + ...claudeStartedReportedOptions( + session, + readClaudeModels(initialized.initialization), + session.fastModeAtStart ? session.optionMutationSequence - 1 : undefined + ) } - await settleClaudeStartupSettings(input, initialized) + void settleClaudeStartupSettings(input, initialized) + return result } async function settleClaudeStartupSettings( - input: Parameters[0], + input: Parameters[0], initialized: ClaudeInitializeFacts ): Promise { const { session } = input const sequence = session.optionMutationSequence - // Only a pick moves it, so this stamp holds however late the host takes `started`. - const optionRevision = input.optionRevision() const settings = await input.readSettings() if (!input.isCurrent()) { return @@ -299,20 +248,13 @@ async function settleClaudeStartupSettings( const startFastMode = writtenSinceRead ? null : admitClaudeStartFastMode(session, facts) input.report({ type: 'options-reported', - // `started` already carried this listing to the host. + // Open already returned the initialize listing to the host. reportedOptions: claudeStartedReportedOptions(session, readClaudeModels(facts.initialization)) .reportedOptions, // The one report of what the config resolved: the readback just applied it. - ...claudeResolvedCatalogListing(session, readClaudeModels(facts.initialization)), - restoreSkippedOptions: [...session.restoreSkippedOptions], - ...claudeRetiredOptions(session), - optionRevision + ...claudeResolvedCatalogListing(session, readClaudeModels(facts.initialization)) }) if (startFastMode !== null) { - void applyClaudeStartFastMode(session, facts, startFastMode, (event) => { - if (input.isCurrent()) { - input.report(event) - } - }) + void applyClaudeStartFastMode(session, facts, startFastMode) } } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index f5d7b67777b2..c454ad63e67a 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -3,11 +3,7 @@ import type { AgentJournalDispatchRejection } from '../../shared/agent-session-f import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { - StructuredAgentSessionOptionsReportedEvent, - StructuredAgentSessionOptionsSkippedEvent, - StructuredAgentSessionStartedEvent -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionOptionsReportedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection, openClaudeStreamJsonConnection @@ -24,7 +20,8 @@ import type { ClaudeBackgroundTaskTracker } from './claude-background-task-track import type { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import type { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' import type { ClaudeAtRestCommandCatalog } from './claude-at-rest-commands' -import type { ClaudeSessionStartup } from './claude-structured-session-startup-state' +import type { ClaudeInitProof } from './claude-structured-init-proof' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' export type ClaudeAuthDiagnostic = { apiKeySourceConfigured: boolean @@ -63,10 +60,7 @@ export type ClaudeStructuredSessionEvent = fence: number } | { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic } - /** The CLI answered initialize: the host may hand the child input from here. */ - | StructuredAgentSessionStartedEvent | StructuredAgentSessionOptionsReportedEvent - | StructuredAgentSessionOptionsSkippedEvent | { type: 'ended' sessionId: string @@ -78,10 +72,6 @@ export type ClaudeStructuredSessionEvent = acquisitionGeneration?: string /** Host clock when the end was observed. */ observedAt?: number - /** The child ended before proving startup, so reacquiring would repeat the same start. */ - startupUnproven?: true - /** The child ended before it answered initialize, so it ran nothing it was handed. */ - startupUnanswered?: true } export type ClaudeLateDispatchOutcome = @@ -206,8 +196,14 @@ export type ClaudeSession = { translator: ClaudeJournalTranslator | null events: StructuredAgentSessionEventSink | undefined unbindReadingControl?: () => void - /** Published at spawn with its saved options; init facts land when startup does. */ - startup: ClaudeSessionStartup + /** Provider identity and initialize proof retained for the host-awaited open. */ + link: AgentSessionProviderHandleLink + initialization: { + proof: ClaudeInitProof + account: ClaudeStructuredLaunch['account'] + resumesTranscript: boolean + onLateRefusal: (error: Error) => void + } } export function mintClaudeAcquisitionGeneration(deps: ClaudeStructuredSessionAdapterDeps): string { diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 9be029def0c8..f1df2d51fe4f 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -17,7 +17,11 @@ import { } 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 { claudeStartupSettled } from './claude-structured-startup-settled-test-support' +import { claudeStartupSettled } from './claude-structured-startup-settled-test-support' +export { + claudeStartupSettled, + claudeOpened +} from './claude-structured-startup-settled-test-support' export const PROVIDER_SESSION_ID = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' @@ -83,10 +87,8 @@ export function fakeClaude( const connections: FakeConnection[] = [] const routes = options.routes ?? {} let replayIndex = 0 - const routed = (subtype: string, params?: Record): unknown => { - const route = routes[subtype] - return route ? route(params) : undefined - } + const routed = (subtype: string, params?: Record): unknown => + routes[subtype]?.(params) const openConnection: typeof openClaudeStreamJsonConnection = async (launch, handlers = {}) => { let cycleInitEmitted = false // Keys mirror the real system/init frame, which carries `model` but no @@ -241,8 +243,7 @@ export function fakeClaude( return { connections, openConnection, routes } } -/** Acquisition resolves only once startup has landed, as suites written before - * publish-first expect; `adapterAtPublishFor` observes the published window itself. */ +/** Direct adapter fixtures await the host-owned open after process publication. */ export function adapterFor( ...args: Parameters ): ClaudeStructuredSessionAdapter { @@ -250,7 +251,7 @@ export function adapterFor( const acquire = adapter.acquire adapter.acquire = async (input) => { const acquisition = await acquire(input) - await adapter['sessions'].get(input.identity.sessionId)?.startup.settled + await claudeStartupSettled(adapter, input.identity.sessionId) return acquisition } return adapter @@ -326,9 +327,7 @@ export function recordingJournalSink(): StructuredAgentSessionEventSink { return { appendItem: () => {}, appendTombstone: () => {}, publish: () => {} } } -export function tick(): Promise { - return new Promise((resolve) => setImmediate(resolve)) -} +export const tick = (): Promise => 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 { diff --git a/src/main/claude/claude-structured-start-fast-mode.ts b/src/main/claude/claude-structured-start-fast-mode.ts index df997380f1a7..4650cf897ada 100644 --- a/src/main/claude/claude-structured-start-fast-mode.ts +++ b/src/main/claude/claude-structured-start-fast-mode.ts @@ -5,7 +5,7 @@ import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' import { readClaudeModels } from './claude-structured-init-proof' import { listedModels } from './claude-structured-model-catalog' import { claudeModelFastModeSupport } from './claude-structured-session-options' -import type { ClaudeStartupFacts, ClaudeStartupReport } from './claude-structured-session-startup' +import type { ClaudeStartupFacts } from './claude-structured-session-startup' import type { ClaudeSession } from './claude-structured-session-state' /** A saved Fast the launch left out (`fastModeAtStart`): on for a new conversation, whose settings @@ -43,14 +43,13 @@ export function admitClaudeStartFastMode( return fastMode } -/** Applies a saved Fast the launch left out after `started`, so nothing waits on it. A refusal - * drops it as main's refused restore did, from the record too; silence keeps it wanted and +/** Applies a saved Fast the launch left out after open, so nothing waits on it. A refusal + * drops the child's value; silence keeps it wanted and * unconfirmed. A write the user made meanwhile owns the option. */ export async function applyClaudeStartFastMode( session: ClaudeSession, facts: ClaudeStartupFacts, - fastMode: string, - report: (event: ClaudeStartupReport) => void + fastMode: string ): Promise { const sequence = session.optionMutationSequence try { @@ -66,7 +65,6 @@ export async function applyClaudeStartFastMode( if (error instanceof ClaudeControlRequestError) { session.options.delete('fastMode') session.restoreSkippedOptions.add('fastMode') - report({ type: 'options-skipped', options: { fastMode } }) } } } diff --git a/src/main/claude/claude-structured-startup-settled-test-support.ts b/src/main/claude/claude-structured-startup-settled-test-support.ts index 721e1acda5a1..1e41f5121cd7 100644 --- a/src/main/claude/claude-structured-startup-settled-test-support.ts +++ b/src/main/claude/claude-structured-startup-settled-test-support.ts @@ -1,10 +1,43 @@ +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import type { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' -/** Resolves once the session's background startup read has landed, faulted, or been ended. */ +const opened = new WeakMap< + ClaudeStructuredSessionAdapter, + Map> +>() + +/** Direct adapter fixtures explicitly join the host-owned open after acquire. */ +export function claudeOpened( + adapter: ClaudeStructuredSessionAdapter, + sessionId: string +): Promise { + let sessions = opened.get(adapter) + if (!sessions) { + sessions = new Map() + opened.set(adapter, sessions) + } + const session = adapter['sessions'].get(sessionId) + if (!session) { + return Promise.reject(new Error(`no Claude session for ${sessionId}`)) + } + const key = `${sessionId}:${session.acquisitionGeneration}` + const previous = sessions.get(key) + if (previous) { + return previous + } + const current = adapter.open({ + sessionId, + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration, + signal: new AbortController().signal + }) + sessions.set(key, current) + return current +} + export async function claudeStartupSettled( adapter: ClaudeStructuredSessionAdapter, sessionId: string ): Promise { - // Element access reaches the adapter's private map, which no production caller needs. - await adapter['sessions'].get(sessionId)?.startup.settled + await claudeOpened(adapter, sessionId) } diff --git a/src/main/codex/__fixtures__/codex-connection-app-server.ts b/src/main/codex/__fixtures__/codex-connection-app-server.ts new file mode 100644 index 000000000000..f14a91b69252 --- /dev/null +++ b/src/main/codex/__fixtures__/codex-connection-app-server.ts @@ -0,0 +1,30 @@ +export const FAKE_APP_SERVER = String.raw` + const readline = require('node:readline') + const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n') + readline.createInterface({ input: process.stdin }).on('line', (line) => { + const message = JSON.parse(line) + if (message.method === 'initialize') return send({ id: message.id, result: {} }) + if (message.method === 'test/env') { + return send({ id: message.id, result: { codexHome: process.env.CODEX_HOME ?? null } }) + } + if (message.method === 'test/cwd') { + return send({ id: message.id, result: { cwd: process.cwd() } }) + } + if (message.method === 'test/notify') { + send({ method: 'turn/started', params: { threadId: 'thread-1', turn: { id: 'turn-7' } } }) + return send({ id: message.id, result: {} }) + } + if (message.method === 'test/ask') { + return send({ id: 99, method: 'item/fileChange/requestApproval', params: { itemId: 'i1' } }) + } + if (message.method === 'test/refuse') { + return send({ id: message.id, error: { code: -32602, message: 'bad params' } }) + } + if (message.method === 'test/missing') { + return send({ id: message.id, error: { code: -32601, message: 'method not found' } }) + } + if (message.id === 99) { + return send({ method: 'test/answered', params: message }) + } + }) +` diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index 3f90182acd41..3ad7f9a32636 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -1,3 +1,4 @@ +import { FAKE_APP_SERVER } from './__fixtures__/codex-connection-app-server' import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' import type { EventEmitter } from 'node:events' import { realpathSync } from 'node:fs' @@ -37,36 +38,6 @@ afterEach(() => { * a stub, but it is the only thing that proves the spawn, the environment, and * both traffic directions actually work end to end. */ -const FAKE_APP_SERVER = String.raw` - const readline = require('node:readline') - const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n') - readline.createInterface({ input: process.stdin }).on('line', (line) => { - const message = JSON.parse(line) - if (message.method === 'initialize') return send({ id: message.id, result: {} }) - if (message.method === 'test/env') { - return send({ id: message.id, result: { codexHome: process.env.CODEX_HOME ?? null } }) - } - if (message.method === 'test/cwd') { - return send({ id: message.id, result: { cwd: process.cwd() } }) - } - if (message.method === 'test/notify') { - send({ method: 'turn/started', params: { threadId: 'thread-1', turn: { id: 'turn-7' } } }) - return send({ id: message.id, result: {} }) - } - if (message.method === 'test/ask') { - return send({ id: 99, method: 'item/fileChange/requestApproval', params: { itemId: 'i1' } }) - } - if (message.method === 'test/refuse') { - return send({ id: message.id, error: { code: -32602, message: 'bad params' } }) - } - if (message.method === 'test/missing') { - return send({ id: message.id, error: { code: -32601, message: 'method not found' } }) - } - if (message.id === 99) { - return send({ method: 'test/answered', params: message }) - } - }) -` async function openFakeServer( handlers: CodexAppServerConnectionHandlers = {}, diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 60ce06db743a..fcabeea5f6b1 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -36,15 +36,12 @@ export type CodexAppServerLaunch = ProviderProcessLaunch const DEFAULT_REQUEST_TIMEOUT_MS = 30_000 -/** - * Spawns `codex app-server`, completes the initialize handshake, and returns a - * connection that stays open until `close()`. Rejects — after reaping the child - * — when the handshake cannot complete. - */ -export async function openCodexAppServerConnection( +/** Builds the transport so an owner can record its process before initialization. */ +async function connectCodexAppServer( launch: CodexAppServerLaunch, handlers: CodexAppServerConnectionHandlers = {}, - spawnImpl: PipedProcessSpawner = spawnProcess + spawnImpl: PipedProcessSpawner, + initialize: boolean ): Promise { const managed = spawnManagedProviderProcess(launch, { spawnImpl, @@ -234,8 +231,10 @@ export async function openCodexAppServerConnection( if (child.pid !== undefined) { await handlers.onSpawned?.(child.pid) } - handshaking = true - await initializeCodexAppServerConnection(connection) + if (initialize) { + handshaking = true + await initializeCodexAppServerConnection(connection) + } } catch (error) { if ((await close()) !== true) { throw new CodexAppServerHandshakeExitUnprovenError(connection, error) @@ -248,3 +247,20 @@ export async function openCodexAppServerConnection( } return connection } + +/** Publishes the transport after its process identity is recorded, before initialization. */ +export function spawnCodexAppServerConnection( + launch: CodexAppServerLaunch, + handlers: CodexAppServerConnectionHandlers = {}, + spawnImpl: PipedProcessSpawner = spawnProcess +): Promise { + return connectCodexAppServer(launch, handlers, spawnImpl, false) +} + +export function openCodexAppServerConnection( + launch: CodexAppServerLaunch, + handlers: CodexAppServerConnectionHandlers = {}, + spawnImpl: PipedProcessSpawner = spawnProcess +): Promise { + return connectCodexAppServer(launch, handlers, spawnImpl, true) +} diff --git a/src/main/codex/codex-app-server-handshake.ts b/src/main/codex/codex-app-server-handshake.ts index 9c89d653baa6..27e2ce6c71bc 100644 --- a/src/main/codex/codex-app-server-handshake.ts +++ b/src/main/codex/codex-app-server-handshake.ts @@ -2,8 +2,11 @@ import type { CodexAppServerConnection } from './codex-app-server-connection-typ const HANDSHAKE_TIMEOUT_MS = 15_000 +/** `timeoutMs`: a native chat's start passes the host's start ceiling, whose attempt owns when a + * start gives up; other callers keep the handshake's own bound. */ export async function initializeCodexAppServerConnection( - connection: CodexAppServerConnection + connection: CodexAppServerConnection, + timeoutMs = HANDSHAKE_TIMEOUT_MS ): Promise { await connection.request( 'initialize', @@ -16,7 +19,7 @@ export async function initializeCodexAppServerConnection( extensions: {} } }, - { timeoutMs: HANDSHAKE_TIMEOUT_MS } + { timeoutMs } ) connection.notify('initialized') } diff --git a/src/main/codex/codex-background-terminals.test.ts b/src/main/codex/codex-background-terminals.test.ts index 818c8d2a6a06..c2cfb8deb24a 100644 --- a/src/main/codex/codex-background-terminals.test.ts +++ b/src/main/codex/codex-background-terminals.test.ts @@ -14,6 +14,7 @@ import { terminateCodexBackgroundTerminals } from './codex-background-terminals' import { + acquireReadyCodexForTest, adapterFor, fakeCodex, identityFor, @@ -93,7 +94,7 @@ function commandItem(status: 'inProgress' | 'completed') { /** A turn that starts a dev server and ends with it still running. */ async function backgroundedDevServer(codex: ReturnType): Promise { - await codex.adapter.acquire({ identity, fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(codex.adapter, { identity, fence: 7, spawnToken: 'spawn-1' }) codex.notify('turn/started', { threadId: THREAD_ID, turn: { id: TURN_ID, status: 'inProgress' } }) codex.notify('item/started', { threadId: THREAD_ID, @@ -226,7 +227,7 @@ describe('Codex background-command Stop', () => { return { data: [], nextCursor: null } } }) - await codex.adapter.acquire({ identity, fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(codex.adapter, { identity, fence: 7, spawnToken: 'spawn-1' }) codex.notify('turn/started', { threadId: THREAD_ID, turn: { id: TURN_ID, status: 'inProgress' } diff --git a/src/main/codex/codex-collab-call-frames-fixture.ts b/src/main/codex/codex-collab-call-frames-fixture.ts index 8926e46dcca9..acadb0b972cb 100644 --- a/src/main/codex/codex-collab-call-frames-fixture.ts +++ b/src/main/codex/codex-collab-call-frames-fixture.ts @@ -1,9 +1,14 @@ +import { + acquireReadyCodexForTest, + fakeCodex, + identityFor, + THREAD_ID +} from './codex-structured-session-adapter-fixture' // Default-mode collab frames as a live session sent them, and the journal rows the real adapter // publishes for them: what a client reads. import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { fakeCodex, identityFor, THREAD_ID } from './codex-structured-session-adapter-fixture' import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' // Shapes as a live default-mode session sent them (codex-cli 0.157); only the sender is remapped. @@ -114,7 +119,7 @@ export async function publishedRows(frames: Frame[]): Promise {}, publish: () => {} } - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/codex/codex-default-mode-subagents.test.ts b/src/main/codex/codex-default-mode-subagents.test.ts index 0b58dd84630f..3086eb6f5a0e 100644 --- a/src/main/codex/codex-default-mode-subagents.test.ts +++ b/src/main/codex/codex-default-mode-subagents.test.ts @@ -1,3 +1,9 @@ +import { + acquireReadyCodexForTest, + fakeCodex, + identityFor, + THREAD_ID +} from './codex-structured-session-adapter-fixture' // Codex's default multi-agent mode announces a helper only by the `collabAgentToolCall` items that // spawn, message, wait on or close it. These frames, through the real adapter, must register that // helper the same way a `subAgentActivity` does: one child in the strip and the host's records, @@ -11,7 +17,6 @@ import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child import { createAgentStatusStore } from '../../shared/agent-status-store' import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { fakeCodex, identityFor, THREAD_ID } from './codex-structured-session-adapter-fixture' import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import { createCodexJournalTranslator } from './codex-structured-journal-translation' @@ -161,7 +166,7 @@ async function session() { appendTombstone: () => {}, publish: () => {} } - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/codex/codex-provider-retry-row.test.ts b/src/main/codex/codex-provider-retry-row.test.ts index 7a49e60f279f..cb96549d6d63 100644 --- a/src/main/codex/codex-provider-retry-row.test.ts +++ b/src/main/codex/codex-provider-retry-row.test.ts @@ -1,3 +1,9 @@ +import { + acquireReadyCodexForTest, + adapterFor, + fakeCodex, + identityFor +} from './codex-structured-session-adapter-fixture' // A Codex stream error it is about to retry is a warning row per attempt, never a red row: the // journal keeps every attempt, and the transcript draws only the latest of a run. import { readFileSync } from 'node:fs' @@ -10,7 +16,6 @@ import type { import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createCodexJournalTranslator } from './codex-structured-journal-translation' -import { adapterFor, fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' const THREAD_ID = 'thread-abc' @@ -171,7 +176,7 @@ describe('a Codex stream error it is about to retry', () => { publish: () => undefined } for (const fence of [7, 8]) { - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence, spawnToken: `spawn-${fence}`, diff --git a/src/main/codex/codex-scripted-child.test-fixture.ts b/src/main/codex/codex-scripted-child.test-fixture.ts new file mode 100644 index 000000000000..c8f4fd5d8a21 --- /dev/null +++ b/src/main/codex/codex-scripted-child.test-fixture.ts @@ -0,0 +1,177 @@ +import type { ScriptedAgentChild } from '../runtime/structured-agent-scripted-child.test-fixture' +import type { + CodexAppServerConnection, + openCodexAppServerConnection +} from './codex-app-server-connection' +import { CodexAppServerRequestError } from './codex-app-server-connection' +import { CodexAppServerTimeoutError } from './codex-app-server-session' +import { codexTurnLifecycleFake } from './codex-turn-lifecycle-fake' + +export const CODEX_SCRIPTED_THREAD = 'scripted-codex-thread' +const THREAD = CODEX_SCRIPTED_THREAD + +type ScriptedCodexRoute = (params: Record | undefined) => unknown + +export type ScriptedCodexChild = ScriptedAgentChild & { + /** Every request any spawn received, in order. */ + requests(): readonly { method: string; params?: Record }[] +} + +/** A held answer gives up on the request's own deadline, as the real connection's does (30 s when + * the caller names none). */ +function heldAnswer( + answer: Promise, + method: string, + timeoutMs = 30_000 +): Promise { + let timer: ReturnType | undefined + const timedOut = new Promise((_resolve, reject) => { + timer = setTimeout( + () => + reject( + new CodexAppServerTimeoutError(`codex app-server ${method} exceeded ${timeoutMs}ms`) + ), + timeoutMs + ) + }) + return Promise.race([answer, timedOut]).finally(() => clearTimeout(timer)) +} + +/** `routes` answers methods the script does not, such as the history reads a rewind makes. */ +export const codexScriptedChild = ( + routes: Readonly> = {} +): ScriptedCodexChild => { + let holdHandshakes = true + let completeTurns = true + let spawned = 0 + let resumed = 0 + let closed = 0 + const prompts: string[] = [] + const requests: { method: string; params?: Record }[] = [] + let newest: ReturnType> | undefined + const open: typeof openCodexAppServerConnection = async (_launch, handlers = {}) => { + spawned += 1 + const gate = Promise.withResolvers() + newest = gate + let ended = false + const turns = codexTurnLifecycleFake(THREAD, () => (method, params) => { + if (!ended) { + handlers.onNotification?.(method, params) + } + }) + const connection: CodexAppServerConnection = { + pid: 10_000 + spawned, + get closed() { + return ended + }, + request: async (method, params, options) => { + if (ended) { + throw new Error('scripted Codex child closed') + } + requests.push({ method, ...(params ? { params } : {}) }) + if (method === 'initialize') { + return holdHandshakes ? heldAnswer(gate.promise, method, options?.timeoutMs) : {} + } + const route = routes[method] + if (method === 'thread/resume') { + resumed += 1 + } + if (route) { + return route(params) + } + if (method === 'thread/start' || method === 'thread/resume') { + return { thread: { id: THREAD }, model: 'scripted-model' } + } + if (method === 'model/list') { + return { + data: [ + { model: 'scripted-model', isDefault: true, supportedReasoningEfforts: [] }, + { + model: 'scripted-alt-model', + supportedReasoningEfforts: [{ reasoningEffort: 'high' }] + } + ], + nextCursor: null + } + } + if (method === 'turn/start' || method === 'turn/steer') { + const input = params?.input + if (Array.isArray(input)) { + for (const block of input) { + if ( + typeof block === 'object' && + block !== null && + 'text' in block && + typeof block.text === 'string' + ) { + prompts.push(block.text) + } + } + } + const answer = + method === 'turn/start' + ? await turns.routes['turn/start']() + : turns.routes['turn/steer'](params) + if (method === 'turn/start') { + turns.start() + } + if (typeof params?.clientUserMessageId === 'string') { + turns.echo(params.clientUserMessageId) + } + if (completeTurns) { + turns.end('completed') + } + return answer + } + if (method === 'turn/interrupt') { + return turns.routes['turn/interrupt'](params) + } + return {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => { + if (!ended) { + ended = true + closed += 1 + gate.reject(new Error('scripted Codex child closed')) + handlers.onExit?.(new Error('scripted Codex child closed'), { expected: true }) + } + return true + } + } + // A gate can be closed before initialization starts or after it has already answered. + void gate.promise.catch(() => {}) + await handlers.onSpawned?.(connection.pid ?? 0) + return connection + } + return { + deps: { + openCodexConnection: open, + readProcessStartTime: async () => 1_700_000_000_000, + resolveCodexCommand: () => '/nonexistent/scripted-codex', + resolveEnvironment: async () => ({}) + }, + get holdHandshakes() { + return holdHandshakes + }, + set holdHandshakes(value) { + holdHandshakes = value + }, + get completeTurns() { + return completeTurns + }, + set completeTurns(value) { + completeTurns = value + }, + releaseHandshake: () => newest?.resolve({}), + failHandshake: (message) => + newest?.reject(new CodexAppServerRequestError('initialize', -32603, message, message)), + prompts: () => prompts, + requests: () => requests, + spawns: () => spawned, + resumes: () => resumed, + closes: () => closed + } +} diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index 2c762121327a..87ea22e0bbe9 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -1,3 +1,9 @@ +import { + acquireReadyCodexForTest, + fakeCodex, + identityFor, + THREAD_ID +} from './codex-structured-session-adapter-fixture' // A Codex session's frames, through the real adapter, into the host's child records: the order the // host receives them in, and the parent row they fold to, written out step by step. @@ -24,7 +30,6 @@ import type { } from '../../shared/agent-session-journal-types' import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { fakeCodex, identityFor, THREAD_ID } from './codex-structured-session-adapter-fixture' import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' const parent = makeStructuredAgentStatusSubject( @@ -135,7 +140,7 @@ async function producer() { moment() } } - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/codex/codex-structured-dispatch-test-support.ts b/src/main/codex/codex-structured-dispatch-test-support.ts index 9ac0f7f31bd9..8c48c86e33cb 100644 --- a/src/main/codex/codex-structured-dispatch-test-support.ts +++ b/src/main/codex/codex-structured-dispatch-test-support.ts @@ -1,3 +1,4 @@ +import { acquireReadyCodexForTest } from './codex-structured-session-adapter-fixture' import { expect, vi } from 'vitest' import type { AgentJournalMessageItem, @@ -117,7 +118,7 @@ export async function acquiredCodexAdapter(input: { agent: 'codex', providerHandle: codexProviderHandle(CODEX_TEST_THREAD_ID) } - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity, fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index d140ba3279e6..547764d426c4 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -209,15 +209,15 @@ export function createCodexJournalTranslator( return suppressionAdmission.accepted ? prompts.handle(event) : suppressionAdmission } if (event.type === 'server-request') { - return genericFrames.appendUnhandled( - `request:${event.method}`, - event.params, - event.threadId - ) + const requestKey = `request:${event.method}` + return genericFrames.appendUnhandled(requestKey, event.params, event.threadId) } if (event.type === 'provider-frame') { return genericFrames.appendUnhandled(event.kind, event.payload, event.threadId) } + if (event.type !== 'notification') { + return CODEX_JOURNAL_ADMITTED + } if (event.method === 'turn/started' || event.method === 'turn/completed') { const admission = event.method === 'turn/started' diff --git a/src/main/codex/codex-structured-live-provider-process.test.ts b/src/main/codex/codex-structured-live-provider-process.test.ts index f6163cf0558c..d9076548228e 100644 --- a/src/main/codex/codex-structured-live-provider-process.test.ts +++ b/src/main/codex/codex-structured-live-provider-process.test.ts @@ -1,11 +1,16 @@ +import { + acquireReadyCodexForTest, + adapterFor, + fakeCodex, + identityFor +} from './codex-structured-session-adapter-fixture' import { describe, expect, it } from 'vitest' -import { adapterFor, fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' describe('Codex adapter liveness for lease renewal', () => { it('holds its acquisition until the connection reports the root exit', async () => { const codex = fakeCodex() const adapter = adapterFor(codex) - const { acquisitionGeneration } = await adapter.acquire({ + const { acquisitionGeneration } = await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' diff --git a/src/main/codex/codex-structured-prompt-ownership.test.ts b/src/main/codex/codex-structured-prompt-ownership.test.ts index a0b2dc4146ed..6b973289775e 100644 --- a/src/main/codex/codex-structured-prompt-ownership.test.ts +++ b/src/main/codex/codex-structured-prompt-ownership.test.ts @@ -1,3 +1,11 @@ +import { + acquireReadyCodexForTest, + THREAD_ID, + acquired, + adapterFor, + fakeCodex, + identityFor +} from './codex-structured-session-adapter-fixture' import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { AGENT_SESSION_ID_MAX_LENGTH } from '../../shared/agent-session-wire' @@ -8,13 +16,6 @@ import type { import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexAppServerRequestError } from './codex-app-server-connection' -import { - THREAD_ID, - acquired, - adapterFor, - fakeCodex, - identityFor -} from './codex-structured-session-adapter-fixture' import { CodexPromptRegistry } from './codex-structured-prompt-replies' import type { CodexStructuredSessionEvent } from './codex-structured-session-state' @@ -231,7 +232,11 @@ describe('Codex live prompt ownership', () => { ).rejects.toThrow(/no longer waiting/) await adapter.closeSession('session-1') - await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 8, + spawnToken: 'spawn-10' + }) await expect( adapter.answerPrompt({ sessionId: 'session-1', @@ -278,7 +283,7 @@ describe('Codex live prompt ownership', () => { 'turn/interrupt': () => completeTurn(codex, 'thread-child', 'child-turn') }) const adapter = adapterFor(codex) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' @@ -335,7 +340,7 @@ describe('Codex live prompt ownership', () => { }) const recorded = lifecycleRecorder() const adapter = adapterFor(codex) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -398,7 +403,7 @@ describe('Codex live prompt ownership', () => { }) const recorded = lifecycleRecorder() const adapter = adapterFor(codex) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -448,7 +453,7 @@ describe('Codex live prompt ownership', () => { const codex = fakeCodex() const recorded = lifecycleRecorder() const adapter = adapterFor(codex) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -517,7 +522,7 @@ describe('Codex live prompt ownership', () => { const codex = fakeCodex() const recorded = lifecycleRecorder(false) const adapter = adapterFor(codex) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -559,7 +564,7 @@ describe('Codex live prompt ownership', () => { } }) const adapter = adapterFor(codex) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -640,7 +645,11 @@ describe('Codex live prompt ownership', () => { } expect(codex.connections[0]?.calls.some((call) => call.method === 'turn/interrupt')).toBe(false) - await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 8, + spawnToken: 'spawn-10' + }) await expect( adapter.cancelTurn({ sessionId: 'session-1', diff --git a/src/main/codex/codex-structured-service-tier.test.ts b/src/main/codex/codex-structured-service-tier.test.ts index aa85f16d06c9..df7d8c4aae19 100644 --- a/src/main/codex/codex-structured-service-tier.test.ts +++ b/src/main/codex/codex-structured-service-tier.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi, type Mock } from 'vitest' import { + acquireReadyCodexForTest, + codexStartReport, THREAD_ID, USER_MESSAGE, adapterFor, @@ -31,7 +33,7 @@ function listing(tier = 'priority') { } async function acquire(adapter: CodexStructuredSessionAdapter, sessionId = 'session-1') { - return adapter.acquire({ + return acquireReadyCodexForTest(adapter, { identity: identityFor(sessionId), fence: 7, spawnToken: `spawn-${sessionId}`, @@ -61,7 +63,7 @@ describe('Codex structured service tier without send-path catalog waits', () => const adapter = adapterFor(codex) await acquire(adapter) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 })).toMatchObject({ + expect(codexStartReport(adapter).reportedOptions).toMatchObject({ serviceTier: 'priority' }) await send(adapter, 'first') @@ -190,7 +192,7 @@ describe('Codex structured service tier without send-path catalog waits', () => const codex = fakeCodex() codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-tier') const adapter = adapterFor(codex, { codexHome: '/codex/home', resumeThreadId: THREAD_ID }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-resume', @@ -198,7 +200,7 @@ describe('Codex structured service tier without send-path catalog waits', () => }) expect(codex.connections[0].calls.some((call) => call.method === 'thread/resume')).toBe(true) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 })).toEqual({ + expect(codexStartReport(adapter).reportedOptions).toEqual({ model: 'gpt-next', serviceTier: 'ultrafast' }) @@ -237,7 +239,11 @@ describe('Codex service tier across clients and rest', () => { 'discovery' ) const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog }) - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-1' + }) await expect( adapter.setOption({ sessionId: 'session-1', key: 'fastMode', value: 'true', fence: 7 }) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 2cbc854e54d1..2121cf50ad98 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -4,7 +4,6 @@ import { isAgentSessionProviderHandleInNamespace } from '../../shared/agent-session-provider-handle-encoding' import { - AgentSessionAcquisitionRefusal, AgentSessionPreSpawnError, type AgentSessionAcquisition, type StructuredAgentSessionAcquireInput @@ -13,28 +12,19 @@ import { closeFailedCodexAcquisition, stopSupersededCodexAcquisition } from './codex-structured-acquisition-lifecycle' -import { CodexBackgroundTaskTracker, codexChildWorkSink } from './codex-background-task-tracker' import { CodexSubagentExecutions } from './codex-subagent-executions' import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { createCodexSessionJournalTranslator } from './codex-structured-session-journal' -import { openCodexAppServerConnection } from './codex-app-server-connection' -import { - codexProviderHandleLink, - codexSpawnedProcessIdentity -} from './codex-structured-owner-identity' -import { openCodexThread } from './codex-structured-thread-open' -import { withCodexVisualsThreadConfig } from './codex-structured-visuals' +import { spawnCodexAppServerConnection } from './codex-app-server-connection' +import { codexSpawnedProcessIdentity } from './codex-structured-owner-identity' import { closeCodexPublishedSession, handleCodexSessionExit } from './codex-structured-session-close' -import { restoredCodexSessionOptions } from './codex-structured-session-options' -import { startBackgroundCodexCatalogRefresh } from './codex-structured-background-catalog' -import { codexAcquireCatalogAccess } from './codex-structured-acquire-catalog' -import { reportedCodexThreadOptions } from './codex-structured-service-tier' +import { openCodexStructuredSession } from './codex-structured-session-start' +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import { assertCodexConnectionOpen, - codexSessionLifecycle, mintCodexAcquisitionGeneration, type CodexAcquisitionRegistry, type CodexAcquisitionAttempt, @@ -92,7 +82,7 @@ export async function acquireCodexStructuredSession(input: { subagentExecutions, prompts: acquisition.prompts }) - const open = deps.openConnection ?? openCodexAppServerConnection + const open = deps.openConnection ?? spawnCodexAppServerConnection const spawnIdentity = codexSpawnedProcessIdentity(acquireInput, deps.readProcessStartTime) try { await stopSupersededCodexAcquisition({ @@ -159,6 +149,33 @@ export async function acquireCodexStructuredSession(input: { ...(acquireInput.onOutput ? { onOutput: acquireInput.onOutput } : {}), onExit: (error, exit) => { try { + if (attempt.acquisitionGeneration && !sessions.has(sessionId)) { + attempt.exitProven = true + attempt.exitError = error + attempt.dispose?.() + acquisitions.deleteIfCurrent(sessionId, attempt) + deps.onEvent?.({ + type: 'ended', + sessionId, + fence: acquireInput.fence, + acquisitionGeneration, + reason: error.message, + cause: + attempt.cancelled && attempt.requestedClose !== false + ? 'requested-close' + : 'unexpected-exit', + failure: attempt.cancelled + ? agentSessionFailureFact('hostFault') + : agentSessionFailureFact('providerExited', { + detail: providerDiagnosticOf(error) + }), + observedAt: deps.now?.() ?? Date.now() + }) + } + if (sessions.get(sessionId)?.connection === acquisition.connection) { + attempt.exitProven = true + attempt.exitError = error + } handleCodexSessionExit({ sessions, sessionId, @@ -187,80 +204,44 @@ export async function acquireCodexStructuredSession(input: { }) } acquisitions.assertCurrent(sessionId, attempt) - const threadLaunch = await withCodexVisualsThreadConfig(connection, launch, { - sessionId, - ...(deps.logger ? { logger: deps.logger } : {}) - }) - acquisitions.assertCurrent(sessionId, attempt) - const opened = await openCodexThread(connection, threadLaunch, deps.requestTimeoutMs) - acquisitions.assertCurrent(sessionId, attempt) - primaryThreadId = opened.threadId - const restoreAdmission = translator?.restoreThread(opened.threadId, opened.thread ?? {}) - if (restoreAdmission && !restoreAdmission.accepted) { - throw AgentSessionAcquisitionRefusal.historyTooLarge( - 'Codex thread history exceeds the bounded restore queue; history was not partially imported.' - ) - } const process = await spawnIdentity.read(connection.pid) acquisitions.assertCurrent(sessionId, attempt) - const acquired: AgentSessionAcquisition = { - process, - link: codexProviderHandleLink({ - threadId: opened.threadId, - ...(opened.supersededThreadId - ? { resumed: false, supersedesThreadId: opened.supersededThreadId } - : { resumed: launch.resumeThreadId !== null }), - fence: acquireInput.fence, - linkId: deps.mintLinkId?.(), - observedAt: deps.now?.() ?? Date.now() - }), - acquisitionGeneration - } assertCodexConnectionOpen(connection, sessionId) - acquisitions.assertCurrent(sessionId, attempt) - const options = restoredCodexSessionOptions(acquireInput.options) - const catalogAccess = codexAcquireCatalogAccess(deps, launch) - acquisitions.assertCurrent(sessionId, attempt) - assertCodexConnectionOpen(connection, sessionId) - acquisitions.deleteIfCurrent(sessionId, attempt) - // Where this session's child work goes: the host's records, after each frame is journaled. - const sink = codexChildWorkSink(sessionId, deps) - const session: CodexSession = { - account, - connection, - ...codexSessionLifecycle(acquireInput.fence, acquired.acquisitionGeneration as string), - threadId: opened.threadId, - historyMode: opened.historyMode, - activeTurnIds: new Set(), - abortedTurnIds: new Set(), - prompts: acquisition.prompts, - options, - reportedOptions: reportedCodexThreadOptions(opened), - ...(catalogAccess ? { catalogAccess } : {}), - dispatchEchoes, - translator, - backgroundTasks: new CodexBackgroundTaskTracker(opened.threadId, subagentExecutions, sink), - forceCloseUnexpected: (reason) => - input.forceCloseUnexpected( - sessionId, - acquireInput.fence, - acquired.acquisitionGeneration as string, - reason - ), - ...(unbindReadingControl ? { unbindReadingControl } : {}) - } - sessions.set(sessionId, session) - for (const event of acquisition.drain()) { - event() + attempt.acquisitionGeneration = acquisitionGeneration + let disposed = false + attempt.dispose = () => { + if (disposed) { + return + } + disposed = true + unbindReadingControl?.() + acquisition.prompts.clear() + acquisition.drain() + translator?.dispose() } - startBackgroundCodexCatalogRefresh({ - session, - sessionId, - sessions, - timeoutMs: deps.requestTimeoutMs, - logger: deps.logger - }) - return acquired + attempt.open = (openInput) => + openCodexStructuredSession( + { + input: acquireInput, + deps, + launch, + account, + sessions, + acquisitions, + attempt, + connection, + translator, + dispatchEchoes, + subagentExecutions, + setPrimaryThreadId: (threadId) => { + primaryThreadId = threadId + }, + forceCloseUnexpected: input.forceCloseUnexpected, + ...(unbindReadingControl ? { unbindReadingControl } : {}) + }, + openInput + ) + return { process, acquisitionGeneration, providerChildPhase: 'starting' } } catch (error) { if (sessions.get(sessionId)?.connection !== acquisition.connection) { return closeFailedCodexAcquisition({ diff --git a/src/main/codex/codex-structured-session-adapter-fixture.ts b/src/main/codex/codex-structured-session-adapter-fixture.ts index 6a7c9f2af5a1..c9994a1c45f3 100644 --- a/src/main/codex/codex-structured-session-adapter-fixture.ts +++ b/src/main/codex/codex-structured-session-adapter-fixture.ts @@ -1,3 +1,8 @@ +import type { + StructuredAgentSessionAcquireInput, + StructuredAgentSessionOptionsReportedEvent +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import type { AgentJournalMessageItem, AgentSessionJournalIdentity @@ -16,6 +21,45 @@ import { import type { CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +const startLinks = new WeakMap< + CodexStructuredSessionAdapter, + Map> +>() +const startReports = new WeakMap< + CodexStructuredSessionAdapter, + Map +>() + +export function codexStartReport(adapter: CodexStructuredSessionAdapter, sessionId = 'session-1') { + const report = startReports.get(adapter)?.get(sessionId) + if (!report) { + throw new Error(`Codex has not started ${sessionId}`) + } + return report +} + +export function codexStartedLink(adapter: CodexStructuredSessionAdapter, sessionId = 'session-1') { + return startLinks.get(adapter)?.get(sessionId) +} + +export async function acquireReadyCodexForTest( + adapter: CodexStructuredSessionAdapter, + input: StructuredAgentSessionAcquireInput +) { + const acquired = await adapter.acquire(input) + const result = await adapter.open({ + sessionId: input.identity.sessionId, + fence: input.fence, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + startReports.get(adapter)?.set(input.identity.sessionId, result) + if (result.link) { + startLinks.get(adapter)?.set(input.identity.sessionId, result.link) + } + return acquired +} + export const THREAD_ID = 'thread-abc' export function identityFor(sessionId: string): AgentSessionJournalIdentity { @@ -81,6 +125,7 @@ export function fakeCodex(routes: Record = {}): { } } connections.push(connection) + await handlers.onSpawned?.(connection.pid ?? 0) return connection } routes['thread/start'] ??= () => ({ @@ -117,7 +162,12 @@ export function adapterFor( deps: Partial = {} ): CodexStructuredSessionAdapter { let acquisitionGeneration = 0 - return new CodexStructuredSessionAdapter({ + const links = new Map>() + const reports = new Map< + string, + StructuredAgentSessionProviderOpenResult | StructuredAgentSessionOptionsReportedEvent + >() + const adapter = new CodexStructuredSessionAdapter({ resolveLaunch: async () => ({ command: 'codex', args: ['app-server'], @@ -126,13 +176,23 @@ export function adapterFor( resumeThreadId: null, ...launch }), - onEvent: (event) => events.push(event), openConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000, now: () => 1_700_000_000_500, mintAcquisitionGeneration: () => `generation-${++acquisitionGeneration}`, - ...deps + ...deps, + onEvent: (event) => { + if (event.type === 'options-reported') { + reports.set(event.sessionId, event) + } else { + events.push(event) + } + deps.onEvent?.(event) + } }) + startReports.set(adapter, reports) + startLinks.set(adapter, links) + return adapter } export async function acquired( @@ -141,6 +201,10 @@ export async function acquired( events: CodexStructuredSessionEvent[] = [] ): Promise { const adapter = adapterFor(codex, launch, events) - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) return adapter } 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 97821f36c928..6392863ba2df 100644 --- a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts +++ b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts @@ -1,3 +1,4 @@ +import { acquireReadyCodexForTest } from './codex-structured-session-adapter-fixture' import { describe, expect, it } from 'vitest' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import type { @@ -125,7 +126,11 @@ async function acquired( events: CodexStructuredSessionEvent[] = [] ): Promise { const adapter = adapterFor(codex, launch, events) - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) return adapter } @@ -133,8 +138,16 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { it('keeps sessions isolated and closes each child once', async () => { const codex = fakeCodex() const adapter = adapterFor(codex) - await adapter.acquire({ identity: identityFor('session-1'), fence: 1, spawnToken: 'spawn-a' }) - await adapter.acquire({ identity: identityFor('session-2'), fence: 1, spawnToken: 'spawn-b' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 1, + spawnToken: 'spawn-a' + }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-2'), + fence: 1, + spawnToken: 'spawn-b' + }) codex.connections[0].handlers.onServerRequest?.({ id: 21, @@ -198,7 +211,11 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { const codex = fakeCodex() const events: CodexStructuredSessionEvent[] = [] const adapter = await acquired(codex, {}, events) - await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 8, + spawnToken: 'spawn-10' + }) const endedBeforeStaleExit = events.filter((event) => event.type === 'ended').length codex.connections[0].handlers.onExit?.(new Error('the superseded child died')) @@ -265,7 +282,7 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { publish: () => {} } const adapter = adapterFor(codex) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 8f36128f7141..5c47eed51fb7 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -1,27 +1,27 @@ +import { + codexStartReport, + codexStartedLink, + acquireReadyCodexForTest, + THREAD_ID, + USER_MESSAGE, + acquired, + adapterFor, + answerWithOpenedTurn, + fakeCodex, + identityFor +} from './codex-structured-session-adapter-fixture' import { describe, expect, it, vi } from 'vitest' import { CodexAppServerRequestError, type openCodexAppServerConnection } from './codex-app-server-connection' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env' import { CodexStructuredSessionAdapter, type CodexStructuredLaunch, type CodexStructuredSessionEvent } from './codex-structured-session-adapter' -import { - THREAD_ID, - USER_MESSAGE, - acquired, - adapterFor, - answerWithOpenedTurn, - fakeCodex, - identityFor -} from './codex-structured-session-adapter-fixture' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' -import { createCodexStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support' -import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture' import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' describe('CodexStructuredSessionAdapter.acquire', () => { @@ -29,55 +29,37 @@ describe('CodexStructuredSessionAdapter.acquire', () => { const codex = fakeCodex({ 'model/list': () => new Promise(() => {}) }) const adapter = adapterFor(codex) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', options: { model: 'gpt-saved', effort: 'low', serviceTier: 'priority' } }) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 })).toEqual({ + expect(codexStartReport(adapter).reportedOptions).toEqual({ model: 'gpt-saved', effort: 'low', serviceTier: 'priority' }) expect(codex.connections[0].calls.map((call) => call.method)).toEqual([ + 'initialize', 'thread/start', 'model/list' ]) }) - it('persists saved model and effort as the next turn uses them', async () => { - const codex = fakeCodex() - const adapter = adapterFor(codex) - await adapter.acquire({ - identity: identityFor('session-1'), - fence: 7, - spawnToken: 'spawn-9', - options: { model: 'gpt-saved', effort: 'low', personality: 'concise' } - }) - - expect( - adapter.readAcquisitionOptions({ - sessionId: 'session-1', - fence: 7, - priorOptions: { model: 'gpt-saved', effort: 'low', personality: 'concise' } - }) - ).toEqual({ model: 'gpt-saved', effort: 'low', personality: 'concise' }) - }) - it('keeps a saved next-turn pick when resuming a thread that reports its previous pick', async () => { const codex = fakeCodex() codex.routes['turn/start'] = () => ({ turn: { id: 'turn-1' } }) const adapter = adapterFor(codex, { resumeThreadId: THREAD_ID }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', options: { model: 'gpt-next', effort: 'low' } }) - const saved = adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 }) + const saved = codexStartReport(adapter).reportedOptions expect(saved).toMatchObject({ model: 'gpt-next', effort: 'low' }) await adapter.dispatch({ sessionId: 'session-1', @@ -92,13 +74,16 @@ describe('CodexStructuredSessionAdapter.acquire', () => { effort: 'low' }) await adapter.closeSession('session-1') - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10', - options: saved + options: { + ...(saved.model ? { model: saved.model } : {}), + ...(saved.effort ? { effort: saved.effort } : {}) + } }) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 8 })).toMatchObject({ + expect(codexStartReport(adapter).reportedOptions).toMatchObject({ model: 'gpt-next', effort: 'low' }) @@ -112,9 +97,13 @@ describe('CodexStructuredSessionAdapter.acquire', () => { }) const adapter = adapterFor(codex) - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 })).toEqual({ + expect(codexStartReport(adapter).reportedOptions).toEqual({ model: 'gpt-live', effort: 'medium' }) @@ -124,83 +113,6 @@ describe('CodexStructuredSessionAdapter.acquire', () => { expect(codex.connections[0].closeCount).toBe(0) }) - it('starts a new thread and reports the process and link the lease will prove', async () => { - const codex = fakeCodex() - const record = agentSessionRecordFixture({ - sessionId: 'session-1', - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/codex/home' } - }) - // The launch its registration composes: the shared seal names the child, not this adapter. - const adapter = adapterFor(codex, {}, [], { - resolveLaunch: createCodexStructuredLaunchResolver({ - store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, - resolveWorkspacePath: async () => '/work/repo', - resolveCommand: () => 'codex', - resolveLaunchArgs: () => [] - }) - }) - - const acquisition = await adapter.acquire({ - identity: identityFor('session-1'), - fence: 7, - spawnToken: 'spawn-9' - }) - - expect(codex.connections[0].launch.env).toEqual({ - [ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV]: 'spawn-9', - CODEX_HOME: '/codex/home', - ORCA_AGENT_SESSION_ID: 'session-1', - ORCA_STRUCTURED_SESSION: '1', - ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/), - ...(process.platform !== 'win32' - ? { ORCA_CLI_BIN_DIR: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin$/) } - : {}), - ORCA_USER_DATA_PATH: expect.any(String), - // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. - PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) - }) - expect(codex.connections[0].launch.cwd).toBe('/work/repo') - expect(codex.connections[0].calls[0]).toEqual({ - method: 'thread/start', - // A chat with no mode asks, stated outright. - params: { - cwd: '/work/repo', - approvalPolicy: 'on-request', - sandbox: 'workspace-write', - approvalsReviewer: 'user' - } - }) - expect(acquisition.process).toEqual({ - hostId: 'host-1', - pid: 4321, - processStartTimeMs: 1_700_000_000_000, - spawnToken: 'spawn-9' - }) - expect(acquisition.link).toEqual({ - linkId: `codex-7-${THREAD_ID}`, - handle: codexProviderHandle(THREAD_ID), - origin: 'created', - mintedAtFence: 7, - observedAt: 1_700_000_000_500 - }) - expect(acquisition.acquisitionGeneration).toBe('generation-1') - }) - - // A thread opened on Codex's configured default and then given a turn on the chosen model - // reads to Codex as a model switch, and it injects the chosen model's whole prompt again. - it('opens the thread on the model the session chose, not on the configured default', async () => { - const codex = fakeCodex() - const adapter = adapterFor(codex, { model: 'gpt-chosen' }) - - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) - - expect(codex.connections[0].calls[0]).toEqual({ - method: 'thread/start', - params: { cwd: '/work/repo', model: 'gpt-chosen' } - }) - }) - it('resumes the thread the durable handle chain names, not the client one', async () => { const codex = fakeCodex() const adapter = adapterFor(codex, { @@ -208,13 +120,13 @@ describe('CodexStructuredSessionAdapter.acquire', () => { resumePath: '/rollouts/thread-proven.jsonl' }) - const acquisition = await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 9, spawnToken: 'spawn-9' }) - expect(codex.connections[0].calls[0]).toEqual({ + expect(codex.connections[0].calls[1]).toEqual({ method: 'thread/resume', params: expect.objectContaining({ threadId: 'thread-proven', @@ -222,8 +134,8 @@ describe('CodexStructuredSessionAdapter.acquire', () => { path: '/rollouts/thread-proven.jsonl' }) }) - expect(acquisition.link.origin).toBe('resumed') - expect(acquisition.link.handle).toEqual(codexProviderHandle('thread-proven')) + expect(codexStartedLink(adapter)?.origin).toBe('resumed') + expect(codexStartedLink(adapter)?.handle).toEqual(codexProviderHandle('thread-proven')) }) it('starts a thread in place of a creation Codex never saved, and says which it replaced', async () => { @@ -241,17 +153,17 @@ describe('CodexStructuredSessionAdapter.acquire', () => { supersedeIfUnsaved: true }) - const acquisition = await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 9, spawnToken: 'spawn-9' }) - expect(codex.connections[0].calls.slice(0, 2).map((call) => call.method)).toEqual([ + expect(codex.connections[0].calls.slice(1, 3).map((call) => call.method)).toEqual([ 'thread/resume', 'thread/start' ]) - expect(acquisition.link).toEqual({ + expect(codexStartedLink(adapter)).toEqual({ linkId: `codex-9-${THREAD_ID}`, handle: codexProviderHandle(THREAD_ID), origin: 'created', @@ -262,37 +174,27 @@ describe('CodexStructuredSessionAdapter.acquire', () => { expect(codex.connections[0].closeCount).toBe(0) }) - it('refuses a resume that lands on a different thread and reaps the child', async () => { + it('rejects a resume that lands on a different thread; the host closes once', async () => { const codex = fakeCodex({ 'thread/resume': () => ({ thread: { id: 'thread-other' } }) }) const adapter = adapterFor(codex, { resumeThreadId: 'thread-proven' }) - + const acquired = await adapter.acquire({ + identity: identityFor('session-1'), + fence: 9, + spawnToken: 'spawn-9' + }) await expect( - adapter.acquire({ identity: identityFor('session-1'), fence: 9, spawnToken: 'spawn-9' }) + adapter.open({ + sessionId: 'session-1', + fence: 9, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) ).rejects.toThrow('resumed thread-other instead of thread-proven') + expect(codex.connections[0].closeCount).toBe(0) + expect(await adapter.closeSession('session-1')).toBe(true) expect(codex.connections[0].closeCount).toBe(1) }) - it('refuses a thread Codex never named', async () => { - const codex = fakeCodex({ 'thread/start': () => ({}) }) - const adapter = adapterFor(codex) - - await expect( - adapter.acquire({ identity: identityFor('session-1'), fence: 1, spawnToken: 'spawn-9' }) - ).rejects.toThrow('did not name the thread') - expect(codex.connections[0].closeCount).toBe(1) - }) - - it('closes the previous child before re-acquiring at a new fence', async () => { - const codex = fakeCodex() - const adapter = await acquired(codex) - - await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) - - expect(codex.connections).toHaveLength(2) - expect(codex.connections[0].closeCount).toBe(1) - expect(codex.connections[1].closeCount).toBe(0) - }) - it('keeps the traffic Codex sends before the session is published', async () => { const codex = fakeCodex() const events: CodexStructuredSessionEvent[] = [] @@ -342,7 +244,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { }) } const adapter = adapterFor(codex, {}, events) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -634,7 +536,11 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog: new AgentModelCatalogStore() }) - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'gpt-5', fence: 7 }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index f515e426153e..a8d250bb1c5f 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -9,6 +9,7 @@ import type { StructuredAgentSessionAdapter, StructuredAgentSessionSetOptionInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenInput } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' @@ -17,7 +18,8 @@ import { CodexStructuredSessionTeardown } from './codex-structured-session-teard import { applyCodexStructuredSessionOption, prepareLiveCodexSessionOptions, - readLiveCodexSessionOptions + readLiveCodexSessionOptions, + readCodexAcquisitionOptions } from './codex-structured-session-options' import { CodexAcquisitionRegistry, @@ -100,6 +102,14 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap this.teardown.forceCloseUnexpected(sessionId, fence, acquisitionGeneration, reason) }) + open = (input: StructuredAgentSessionProviderOpenInput) => { + const attempt = this.acquisitions.get(input.sessionId) + if (!attempt?.open) { + return Promise.reject(new Error(`no spawned codex child for session ${input.sessionId}`)) + } + return attempt.open(input) + } + /** Buffers pre-publication events and drops events from superseded children. */ private deliver( acquisition: CodexAcquisitionAttempt['window'], @@ -177,6 +187,10 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap // `ended` is set in the same turn as the connection's own exit report. holdsLiveProviderProcess = (sessionId: string, acquisitionGeneration: string): boolean => { const session = this.sessions.get(sessionId) + const starting = this.acquisitions.get(sessionId) + if (starting?.acquisitionGeneration === acquisitionGeneration) { + return !starting.exitProven && starting.window.connection?.pid !== undefined + } return ( session?.acquisitionGeneration === acquisitionGeneration && session.connection.pid !== undefined && @@ -302,28 +316,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap sessionId: string fence: number priorOptions?: Readonly> - }) => { - const session = this.session(input.sessionId) - const options = { - ...Object.fromEntries( - Object.entries(input.priorOptions ?? {}).filter(([key]) => !isCodexTurnOptionKey(key)) - ), - ...Object.fromEntries(session.options) - } - const reported = session.reportedOptions - // The thread's effort belongs to the thread's model, not to a different saved one. - if ( - options.effort === undefined && - reported.effort && - (options.model === undefined || options.model === reported.model) - ) { - options.effort = reported.effort - } - if (options.model === undefined && reported.model) { - options.model = reported.model - } - return Object.keys(options).length > 0 ? options : undefined - } + }) => readCodexAcquisitionOptions(this.session(input.sessionId), input.priorOptions) closeSession = (sessionId: string): Promise => this.teardown.close(sessionId) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) 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 466cadce8a13..aa2af71ae6b9 100644 --- a/src/main/codex/codex-structured-session-background-tasks.test.ts +++ b/src/main/codex/codex-structured-session-background-tasks.test.ts @@ -1,3 +1,4 @@ +import { acquireReadyCodexForTest } from './codex-structured-session-adapter-fixture' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' @@ -99,7 +100,7 @@ async function adapterWithSession( onChildWorkEvidence: (sessionId, evidence) => published.push({ sessionId, evidence: evidence.map((edge) => edge.type) }) }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identity('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -157,7 +158,11 @@ describe("codex background tasks reach the host's child records", () => { const published: Published[] = [] const { adapter, codex } = await adapterWithSession(published) const oldExit = codex.handlers().onExit - await adapter.acquire({ identity: identity('session-1'), fence: 8, spawnToken: 'spawn-10' }) + await acquireReadyCodexForTest(adapter, { + identity: identity('session-1'), + fence: 8, + spawnToken: 'spawn-10' + }) codex.handlers().onNotification?.('turn/started', { threadId: CHILD_ID, turn: { id: 'replacement-child-turn' } diff --git a/src/main/codex/codex-structured-session-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts index f9f9f72ccb13..7a541a2f59ec 100644 --- a/src/main/codex/codex-structured-session-cancel.test.ts +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -1,3 +1,4 @@ +import { acquireReadyCodexForTest } from './codex-structured-session-adapter-fixture' import { describe, expect, it, vi } from 'vitest' // Every way Orca reads or kills processes, never settling: a Stop that reaches for one hangs. @@ -128,7 +129,7 @@ async function acquired( readProcessStartTime: async () => 1_700_000_000_000, ...overrides }) - await adapter.acquire({ identity: identity(), fence: 7, spawnToken: 'spawn-9' }) + await acquireReadyCodexForTest(adapter, { identity: identity(), fence: 7, spawnToken: 'spawn-9' }) return adapter } diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index e0004a0f7373..cb3121fad606 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -1,3 +1,4 @@ +import { acquireReadyCodexForTest } from './codex-structured-session-adapter-fixture' import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { describe, expect, it, vi } from 'vitest' @@ -161,8 +162,8 @@ describe('Codex structured session close lifecycle', () => { const { adapter } = adapterFixture() const input = { identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' } - const first = await adapter.acquire(input) - const second = await adapter.acquire(input) + const first = await acquireReadyCodexForTest(adapter, input) + const second = await acquireReadyCodexForTest(adapter, input) expect(first.acquisitionGeneration).toBe('generation-1') expect(second.acquisitionGeneration).toBe('generation-2') @@ -170,9 +171,17 @@ describe('Codex structured session close lifecycle', () => { it('distinguishes an observed provider death from a requested close', async () => { const { adapter, connections, events } = adapterFixture() - await adapter.acquire({ identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(adapter, { + identity: identity('session-1'), + fence: 7, + spawnToken: 'spawn-1' + }) connections[0]?.handlers.onExit?.(new Error('provider exited')) - await adapter.acquire({ identity: identity('session-2'), fence: 9, spawnToken: 'spawn-2' }) + await acquireReadyCodexForTest(adapter, { + identity: identity('session-2'), + fence: 9, + spawnToken: 'spawn-2' + }) await adapter.closeSession('session-2') @@ -192,7 +201,11 @@ describe('Codex structured session close lifecycle', () => { it('force-close preserves unexpected-exit evidence when the adapter reports exit during close', async () => { const { adapter, connections, events } = adapterFixture() - await adapter.acquire({ identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(adapter, { + identity: identity('session-1'), + fence: 7, + spawnToken: 'spawn-1' + }) const current = connections[0] if (!current) { throw new Error('missing connection') diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index be214a25d594..d5a0a90ec5b9 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -91,9 +91,13 @@ export async function closeCodexPublishedSession( ) { return false } + if (session.ended && session.connection.closed) { + sessions.delete(sessionId) + return true + } // Sink-failure recovery force-closes the child but must preserve the // observed-exit cause so host lease settlement runs as an unexpected death. - session.orcaClose = { + session.orcaClose ??= { requested: options?.requestedClose ?? true, reason: options?.unexpectedReason ?? new Error('codex session closed') } @@ -124,16 +128,33 @@ export async function closeCodexSession( sessions: Map, acquisitions: CodexAcquisitionRegistry, onEvent?: (event: CodexStructuredSessionEvent) => void, - logger?: StructuredAgentSessionLogger + logger?: StructuredAgentSessionLogger, + requestedClose = true ): Promise { const attempt = acquisitions.get(sessionId) - if (!(await cancelCodexAcquisitionAttempt(attempt))) { + const published = sessions.get(sessionId) + if (attempt && published?.connection === attempt.window.connection) { + attempt.cancelled = true + attempt.requestedClose = requestedClose + const closed = await closeCodexPublishedSession(sessions, sessionId, onEvent, { + requestedClose, + ...(logger ? { logger } : {}) + }) + if (closed) { + acquisitions.deleteIfCurrent(sessionId, attempt) + } + return closed + } + if (!(await cancelCodexAcquisitionAttempt(attempt, requestedClose))) { return false } if (attempt) { acquisitions.deleteIfCurrent(sessionId, attempt) } - return closeCodexPublishedSession(sessions, sessionId, onEvent, logger ? { logger } : {}) + return closeCodexPublishedSession(sessions, sessionId, onEvent, { + requestedClose, + ...(logger ? { logger } : {}) + }) } export async function closeAllCodexSessions( diff --git a/src/main/codex/codex-structured-session-exit-blame.test.ts b/src/main/codex/codex-structured-session-exit-blame.test.ts index 591f0e61ae8f..7d9c6e57150e 100644 --- a/src/main/codex/codex-structured-session-exit-blame.test.ts +++ b/src/main/codex/codex-structured-session-exit-blame.test.ts @@ -1,10 +1,15 @@ +import { + acquireReadyCodexForTest, + adapterFor, + fakeCodex, + identityFor +} from './codex-structured-session-adapter-fixture' // Only the Codex app-server child's own exit says Codex stopped; a close Orca made — a journal // sink that could not take a frame, a forced close — is Orca's. import { describe, expect, it, vi } from 'vitest' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { adapterFor, fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' import type { CodexStructuredSessionEvent } from './codex-structured-session-state' function sink(overrides: Partial = {}) { @@ -15,7 +20,12 @@ async function startedCodex(events: StructuredAgentSessionEventSink) { const codex = fakeCodex() const emitted: CodexStructuredSessionEvent[] = [] const adapter = adapterFor(codex, {}, emitted) - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 's', events }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 's', + events + }) const ended = async () => { await vi.waitFor(() => expect(emitted.some((event) => event.type === 'ended')).toBe(true)) return emitted.filter((event) => event.type === 'ended') diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index 9898029e14da..693bd1fa9ccc 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -1,3 +1,11 @@ +import { + acquireReadyCodexForTest, + USER_MESSAGE, + adapterFor, + answerWithOpenedTurn, + fakeCodex, + identityFor +} from './codex-structured-session-adapter-fixture' import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { describe, expect, it, vi } from 'vitest' @@ -13,13 +21,6 @@ import { reportedCodexThreadOptions } from './codex-structured-service-tier' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexSession } from './codex-structured-session-state' import { startCodexTurn } from './codex-structured-turn-start' -import { - USER_MESSAGE, - adapterFor, - answerWithOpenedTurn, - fakeCodex, - identityFor -} from './codex-structured-session-adapter-fixture' import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' function optionSession(request: CodexAppServerConnection['request']): CodexSession { @@ -688,7 +689,11 @@ describe('Codex option picks before the model list arrives', () => { const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog: new AgentModelCatalogStore() }) - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-1' + }) await vi.waitFor(() => expect(codex.connections[0].calls.some((call) => call.method === 'model/list')).toBe(true) ) @@ -702,11 +707,6 @@ describe('Codex option picks before the model list arrives', () => { effort: 'low', serviceTier: 'priority' }) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 })).toEqual({ - model: 'gpt-next', - effort: 'low', - serviceTier: 'priority' - }) await adapter.dispatch({ sessionId: 'session-1', clientMessageId: 'first', diff --git a/src/main/codex/codex-structured-session-options.ts b/src/main/codex/codex-structured-session-options.ts index 9e876bbe1183..e9118f111ac1 100644 --- a/src/main/codex/codex-structured-session-options.ts +++ b/src/main/codex/codex-structured-session-options.ts @@ -26,6 +26,48 @@ export function restoredCodexSessionOptions( return new Map(Object.entries(options ?? {}).filter(([key]) => isCodexTurnOptionKey(key))) } +export function readCodexAcquisitionOptions( + session: CodexSession, + priorOptions: Readonly> | undefined +): Readonly> | undefined { + const options = { + ...Object.fromEntries( + Object.entries(priorOptions ?? {}).filter(([key]) => !isCodexTurnOptionKey(key)) + ), + ...Object.fromEntries(session.options) + } + const reported = session.reportedOptions + // The thread's effort belongs to the thread's model, not to a different saved one. + if ( + options.effort === undefined && + reported.effort && + (options.model === undefined || options.model === reported.model) + ) { + options.effort = reported.effort + } + if (options.model === undefined && reported.model) { + options.model = reported.model + } + return Object.keys(options).length > 0 ? options : undefined +} + +/** Startup reports the thread's defaults together with the saved next-turn intent. */ +export function reportedCodexSessionOptions( + session: CodexSession +): AgentSessionOptionsResult['current'] { + const model = session.options.get('model') ?? session.reportedOptions.model ?? '' + const effort = + session.options.get('effort') ?? + (model === session.reportedOptions.model ? session.reportedOptions.effort : undefined) + // The saved tier, which the next turn sends as-is. + const serviceTier = session.options.get('serviceTier') + return { + model, + ...(effort ? { effort } : {}), + ...(serviceTier ? { serviceTier } : {}) + } +} + export type { CodexSessionOptionCatalog } from './codex-structured-model-catalog' export { readCodexStructuredSessionOptionCatalog } from './codex-structured-model-catalog' diff --git a/src/main/codex/codex-structured-session-prompts.test.ts b/src/main/codex/codex-structured-session-prompts.test.ts index 4a61f101a7d7..e9771afba1fc 100644 --- a/src/main/codex/codex-structured-session-prompts.test.ts +++ b/src/main/codex/codex-structured-session-prompts.test.ts @@ -1,14 +1,15 @@ -import { describe, expect, it, vi } from 'vitest' -import { AgentSessionPromptAnswerRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import { + acquireReadyCodexForTest, THREAD_ID, acquired, adapterFor, fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' +import { describe, expect, it, vi } from 'vitest' +import { AgentSessionPromptAnswerRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' describe('CodexStructuredSessionAdapter prompts', () => { function askApproval(codex: ReturnType): void { @@ -61,7 +62,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { tryAppendItem: vi.fn(() => ({ accepted: false as const, reason: 'closed' as const })) } const adapter = adapterFor(codex, {}, events) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -101,7 +102,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { tryAppendItem: vi.fn(() => ({ accepted: false as const, reason: 'backpressure' as const })) } const adapter = adapterFor(codex, {}, events) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -126,7 +127,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { tryAppendItem: vi.fn(() => ({ accepted: false as const, reason: 'failed' as const })) } const adapter = adapterFor(codex, {}, events) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/codex/codex-structured-session-shutdown.test.ts b/src/main/codex/codex-structured-session-shutdown.test.ts index 44ed5c93d411..189a268d9546 100644 --- a/src/main/codex/codex-structured-session-shutdown.test.ts +++ b/src/main/codex/codex-structured-session-shutdown.test.ts @@ -1,3 +1,4 @@ +import { acquireReadyCodexForTest } from './codex-structured-session-adapter-fixture' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { @@ -94,7 +95,11 @@ describe('CodexStructuredSessionAdapter shutdown', () => { readProcessStartTime: async () => 1_700_000_000_000 }) - await adapter.acquire({ identity: identity(), fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(adapter, { + identity: identity(), + fence: 7, + spawnToken: 'spawn-1' + }) await expect(adapter.closeAll()).rejects.toThrow( 'codex structured session shutdown could not prove every child stopped' ) diff --git a/src/main/codex/codex-structured-session-start.ts b/src/main/codex/codex-structured-session-start.ts new file mode 100644 index 000000000000..4355ed48c2cb --- /dev/null +++ b/src/main/codex/codex-structured-session-start.ts @@ -0,0 +1,159 @@ +import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' +import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionProviderOpenInput, + StructuredAgentSessionProviderOpenResult +} from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' +import { initializeCodexAppServerConnection } from './codex-app-server-handshake' +import type { CodexAppServerConnection } from './codex-app-server-connection' +import { CodexBackgroundTaskTracker, codexChildWorkSink } from './codex-background-task-tracker' +import type { CodexSubagentExecutions } from './codex-subagent-executions' +import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' +import type { CodexJournalTranslator } from './codex-structured-journal-translation' +import { codexProviderHandleLink } from './codex-structured-owner-identity' +import { openCodexThread } from './codex-structured-thread-open' +import { withCodexVisualsThreadConfig } from './codex-structured-visuals' +import { + restoredCodexSessionOptions, + reportedCodexSessionOptions +} from './codex-structured-session-options' +import { startBackgroundCodexCatalogRefresh } from './codex-structured-background-catalog' +import { codexAcquireCatalogAccess } from './codex-structured-acquire-catalog' +import { reportedCodexThreadOptions } from './codex-structured-service-tier' +import { + assertCodexConnectionOpen, + codexSessionLifecycle, + type CodexAcquisitionRegistry, + type CodexAcquisitionAttempt, + type CodexSession, + type CodexStructuredLaunch, + type CodexStructuredSessionAdapterDeps +} from './codex-structured-session-state' +import type { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' +import { STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' + +type CodexOpenContext = { + input: StructuredAgentSessionAcquireInput + deps: CodexStructuredSessionAdapterDeps + launch: CodexStructuredLaunch + account: AgentSessionAccountKind | undefined + sessions: Map + acquisitions: CodexAcquisitionRegistry + attempt: CodexAcquisitionAttempt + connection: CodexAppServerConnection + translator: CodexJournalTranslator | null + dispatchEchoes: CodexDispatchEchoes + subagentExecutions: CodexSubagentExecutions + setPrimaryThreadId: (threadId: string) => void + forceCloseUnexpected: CodexStructuredSessionTeardown['forceCloseUnexpected'] + unbindReadingControl?: () => void +} + +/** The host owns the open promise and closes the spawned child if this rejects. */ +export async function openCodexStructuredSession( + start: CodexOpenContext, + openInput: StructuredAgentSessionProviderOpenInput +): Promise { + const { input, deps, launch, sessions, acquisitions, attempt, connection, translator } = start + const sessionId = input.identity.sessionId + const generation = attempt.acquisitionGeneration + if ( + generation === undefined || + openInput.sessionId !== sessionId || + openInput.fence !== input.fence || + openInput.acquisitionGeneration !== generation + ) { + throw new Error('codex open does not match the spawned child') + } + const current = (): void => { + throwIfSignalAborted(openInput.signal) + if (attempt.exitError) { + throw attempt.exitError + } + acquisitions.assertCurrent(sessionId, attempt) + assertCodexConnectionOpen(connection, sessionId) + } + current() + await waitForPromiseWithSignal( + initializeCodexAppServerConnection(connection, STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS), + openInput.signal + ) + current() + const threadLaunch = await waitForPromiseWithSignal( + withCodexVisualsThreadConfig(connection, launch, { + sessionId, + ...(deps.logger ? { logger: deps.logger } : {}) + }), + openInput.signal + ) + current() + const opened = await waitForPromiseWithSignal( + openCodexThread( + connection, + threadLaunch, + STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS, + openInput.signal + ), + openInput.signal + ) + current() + start.setPrimaryThreadId(opened.threadId) + const restored = translator?.restoreThread(opened.threadId, opened.thread ?? {}) + if (restored && !restored.accepted) { + throw AgentSessionAcquisitionRefusal.historyTooLarge( + 'Codex thread history exceeds the bounded restore queue; history was not partially imported.' + ) + } + const catalogAccess = codexAcquireCatalogAccess(deps, launch) + const session: CodexSession = { + account: start.account, + connection, + ...codexSessionLifecycle(input.fence, generation), + threadId: opened.threadId, + historyMode: opened.historyMode, + activeTurnIds: new Set(), + abortedTurnIds: new Set(), + prompts: attempt.window.prompts, + options: restoredCodexSessionOptions(input.options), + reportedOptions: reportedCodexThreadOptions(opened), + ...(catalogAccess ? { catalogAccess } : {}), + dispatchEchoes: start.dispatchEchoes, + translator, + backgroundTasks: new CodexBackgroundTaskTracker( + opened.threadId, + start.subagentExecutions, + codexChildWorkSink(sessionId, deps) + ), + forceCloseUnexpected: (reason) => + start.forceCloseUnexpected(sessionId, input.fence, generation, reason), + ...(start.unbindReadingControl ? { unbindReadingControl: start.unbindReadingControl } : {}) + } + current() + sessions.set(sessionId, session) + for (const event of attempt.window.drain()) { + event() + } + current() + acquisitions.deleteIfCurrent(sessionId, attempt) + startBackgroundCodexCatalogRefresh({ + session, + sessionId, + sessions, + timeoutMs: deps.requestTimeoutMs, + logger: deps.logger + }) + return { + link: codexProviderHandleLink({ + threadId: opened.threadId, + ...(opened.supersededThreadId + ? { resumed: false, supersedesThreadId: opened.supersededThreadId } + : { resumed: launch.resumeThreadId !== null }), + fence: input.fence, + linkId: deps.mintLinkId?.(), + observedAt: deps.now?.() ?? Date.now() + }), + reportedOptions: reportedCodexSessionOptions(session) + } +} diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index e0e16a86e555..6de26ff66ae2 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -20,13 +20,14 @@ import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexJournalTranslator } from './codex-structured-journal-translation' -import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import type { StructuredLaunchRequest } from '../runtime/structured-agent-launch-composition' import type { AgentModelCatalogSessionAccess, AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import type { StructuredAgentSessionProviderOpen } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import type { NativeChatVisualsLaunch } from '../native-chat/native-chat-visuals-delivery' export type CodexSessionCatalogAccess = AgentModelCatalogSessionAccess @@ -84,7 +85,7 @@ export type CodexStructuredSessionEvent = codexItemId: string promptKey: string } - | StructuredAgentSessionEndedEvent + | StructuredAgentSessionLifecycleEvent /** Translator-only compatibility for callers that do not participate in host recovery. */ | { type: 'ended'; sessionId: string; reason: string; observedAt?: number } @@ -201,8 +202,13 @@ export function requireLiveCodexSession( export type CodexAcquisitionAttempt = { window: CodexAcquisitionWindow + acquisitionGeneration?: string + open?: StructuredAgentSessionProviderOpen + dispose?: () => void cancelled: boolean + requestedClose?: boolean exitProven: boolean + exitError?: Error finished: Promise finish: () => void } @@ -287,17 +293,24 @@ export class CodexAcquisitionRegistry { } export async function cancelCodexAcquisitionAttempt( - attempt: CodexAcquisitionAttempt | undefined + attempt: CodexAcquisitionAttempt | undefined, + requestedClose = true ): Promise { if (!attempt) { return true } - return cancelProcessAcquisition({ + const closed = await cancelProcessAcquisition({ cancel: () => { attempt.cancelled = true + attempt.requestedClose = requestedClose }, connection: () => attempt.window.connection, exitProven: () => attempt.exitProven, finished: attempt.finished }) + if (closed) { + attempt.exitProven = true + attempt.dispose?.() + } + return closed } diff --git a/src/main/codex/codex-structured-session-teardown.ts b/src/main/codex/codex-structured-session-teardown.ts index e7395fb3c845..12316dcbe800 100644 --- a/src/main/codex/codex-structured-session-teardown.ts +++ b/src/main/codex/codex-structured-session-teardown.ts @@ -29,7 +29,9 @@ export class CodexStructuredSessionTeardown { constructor(private readonly deps: CodexStructuredSessionTeardownDeps) {} close = async (sessionId: string): Promise => { - const connection = this.deps.sessions.get(sessionId)?.connection + const connection = + this.deps.sessions.get(sessionId)?.connection ?? + this.deps.acquisitions.get(sessionId)?.window.connection const closed = this.settled( sessionId, await closeCodexSession( @@ -50,11 +52,13 @@ export class CodexStructuredSessionTeardown { } forceClose = async (sessionId: string): Promise => { - const closed = await closeCodexPublishedSession( - this.deps.sessions, + const closed = await closeCodexSession( sessionId, + this.deps.sessions, + this.deps.acquisitions, this.deps.onEvent, - { requestedClose: false } + this.deps.logger, + false ) return this.settled(sessionId, closed) } diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index c6e686869b7a..e3200b07c352 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -5,6 +5,7 @@ // recording it would make the durable handle chain lie about what this session // actually proved. +import { throwIfSignalAborted } from '../../shared/abort-signal-reason' import { isCodexAppServerRequestError, type CodexAppServerConnection @@ -59,7 +60,8 @@ export async function openCodexThread( /** Config overrides for this thread, on start and resume alike. */ threadConfig?: Record }, - timeoutMs: number | undefined + timeoutMs: number | undefined, + signal?: AbortSignal ): Promise { const resumeThreadId = launch.resumeThreadId const threadSettings = { @@ -67,12 +69,14 @@ export async function openCodexThread( ...launch.permissionPolicy, ...(launch.threadConfig ? { config: launch.threadConfig } : {}) } - const startThread = (): Promise => - connection.request( + const startThread = (): Promise => { + throwIfSignalAborted(signal) + return connection.request( 'thread/start', { ...threadSettings, ...(launch.model ? { model: launch.model } : {}) }, { timeoutMs } ) + } let supersededThreadId: string | undefined let opened: unknown if (!resumeThreadId) { @@ -85,12 +89,14 @@ export async function openCodexThread( ...(launch.resumePath ? { path: launch.resumePath } : {}) } try { + throwIfSignalAborted(signal) opened = await connection.request( 'thread/resume', { ...resumeParams, excludeTurns: true }, { timeoutMs } ) } catch (error) { + throwIfSignalAborted(signal) if (!launch.supersedeIfUnsaved || !isCodexNoRolloutError(error, resumeThreadId)) { throw error } diff --git a/src/main/codex/codex-structured-turn-steer.test.ts b/src/main/codex/codex-structured-turn-steer.test.ts index e6c654c00775..cc2ade9f4a5d 100644 --- a/src/main/codex/codex-structured-turn-steer.test.ts +++ b/src/main/codex/codex-structured-turn-steer.test.ts @@ -38,7 +38,9 @@ async function rig(routes: Record) { const methods = () => connection.calls .map(({ method }) => method) - .filter((method) => method !== 'model/list' && method !== 'config/read') + .filter( + (method) => method !== 'initialize' && method !== 'model/list' && method !== 'config/read' + ) const endTurn = (turnId: string, status: 'completed' | 'interrupted') => connection.handlers.onNotification?.('turn/completed', { threadId: CODEX_TEST_THREAD_ID, @@ -215,7 +217,7 @@ describe('a Codex send made after Codex answered an earlier one, before it opene expect(await settledWithin(sending)).toBe('held') const methods = () => rig.codex.connections[0]!.calls.map(({ method }) => method).filter( - (method) => method !== 'model/list' && method !== 'config/read' + (method) => method !== 'initialize' && method !== 'model/list' && method !== 'config/read' ) expect(methods()).toEqual(['thread/start', 'turn/start']) return { ...rig, sending, methods } diff --git a/src/main/codex/codex-structured-visuals.test.ts b/src/main/codex/codex-structured-visuals.test.ts index 7b45206e2dfa..95f31f292ddc 100644 --- a/src/main/codex/codex-structured-visuals.test.ts +++ b/src/main/codex/codex-structured-visuals.test.ts @@ -7,6 +7,7 @@ import { CodexAppServerRequestError } from './codex-app-server-request-error' import { CodexAppServerTimeoutError } from './codex-app-server-session' import { adapterFor, + acquireReadyCodexForTest, fakeCodex, identityFor, THREAD_ID @@ -192,7 +193,11 @@ async function acquired( prepareVisuals: async () => launch.visuals ?? null }) }) - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) } describe('a Codex chat acquisition with visuals', () => { @@ -203,6 +208,7 @@ describe('a Codex chat acquisition with visuals', () => { const connection = codex.connections[0]! const methods = connection.calls.map((call) => call.method) const open = resumeThreadId ? 'thread/resume' : 'thread/start' + expect(methods[0]).toBe('initialize') expect(methods.indexOf('skills/extraRoots/set')).toBeLessThan(methods.indexOf(open)) expect(methods.indexOf('config/read')).toBeLessThan(methods.indexOf(open)) expect(connection.calls.find((call) => call.method === open)?.params).toMatchObject({ @@ -218,9 +224,10 @@ describe('a Codex chat acquisition with visuals', () => { await acquired(codex, { env: { [NATIVE_CHAT_VISUALS_DIR_ENV]: '/other' } }) const connection = codex.connections[0]! const methods = connection.calls.map((call) => call.method) - expect(methods[0]).toBe('thread/start') + // The adapter sends `initialize` itself now; nothing comes between it and the thread. + expect(methods.slice(0, 2)).toEqual(['initialize', 'thread/start']) expect(methods).not.toContain('skills/extraRoots/set') - expect(connection.calls[0]?.params).not.toHaveProperty('config') + expect(connection.calls[1]?.params).not.toHaveProperty('config') expect(connection.launch.env).not.toHaveProperty(NATIVE_CHAT_VISUALS_DIR_ENV) }) }) diff --git a/src/main/codex/codex-subagent-interrupts.test.ts b/src/main/codex/codex-subagent-interrupts.test.ts index ea04708fe45c..522b14d19883 100644 --- a/src/main/codex/codex-subagent-interrupts.test.ts +++ b/src/main/codex/codex-subagent-interrupts.test.ts @@ -4,6 +4,7 @@ import { codexPrimaryCommandTaskId } from '../../shared/structured-session-foreg import { CodexAppServerRequestError } from './codex-app-server-connection' import { CodexAppServerTimeoutError } from './codex-app-server-session' import { + acquireReadyCodexForTest, adapterFor, fakeCodex, identityFor, @@ -34,7 +35,7 @@ async function runningHelper(routes: Record) { const live = () => codex.connections.at(-1)! const notify = (method: string, params: unknown): void => live().handlers.onNotification?.(method, params) - await adapter.acquire({ identity, fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(adapter, { identity, fence: 7, spawnToken: 'spawn-1' }) notify('turn/started', { threadId: THREAD_ID, turn: { id: 'turn-1', status: 'inProgress' } }) notify('item/started', { threadId: THREAD_ID, diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts index 9dcec6070fc2..bd40aa9690ed 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' import { agentModelCatalogFingerprint } from './agent-model-catalog-fingerprint' import { createAgentModelCatalogService } from './agent-model-catalog-service' import { @@ -48,6 +52,53 @@ function rig(agent = 'grok') { } describe('a saved-only catalog read', () => { + it('does not relaunch a released chat to read models after startup failed', async () => { + let now = 1_000 + const record = { + ...agentSessionRecordFixture( + agentSessionLeaseFixture({ + claimStatus: 'released', + ownerProcess: null, + deathEvidence: { + kind: 'pid-absent', + detail: 'reservation failed before spawn', + observedAt: now, + ownerFence: 6 + } + }) + ), + provider: 'grok', + accountHome: HOME + } + const store = new AgentModelCatalogStore({ now: () => now }) + const probe = vi.fn(async () => listing('grok-4.7')) + const service = createAgentModelCatalogService({ + store, + getRecord: (sessionId) => (sessionId === record.sessionId ? record : undefined), + drivesRecord: () => true, + resolveAccountHome: async () => HOME, + probes: { grok: probe } + }) + + expect( + await service.read({ agent: 'grok', sessionId: record.sessionId, waitForListing: true }) + ).toEqual({ + origin: 'unknown' + }) + expect(probe).not.toHaveBeenCalled() + + store.recordSuccess(FINGERPRINT, 'grok', listing('grok-4.7'), 'discovery') + now += AGENT_MODEL_CATALOG_FRESH_MS + expect(await service.read({ agent: 'grok', sessionId: record.sessionId })).toMatchObject({ + origin: 'probe', + models: [{ id: 'grok-4.7' }] + }) + expect(probe).not.toHaveBeenCalled() + + await service.read({ agent: 'grok' }) + expect(probe).toHaveBeenCalledOnce() + }) + it('answers from the saved entry and starts nothing, however old the entry is', async () => { const { store, probe, service, advance } = rig() store.recordSuccess(FINGERPRINT, 'grok', listing('grok-4.7'), 'discovery') 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 de5a5c26b60c..b0530e834e58 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 @@ -274,7 +274,12 @@ export function createAgentModelCatalogService( ...(unavailable ? { unavailable } : {}) } } - if (params.savedOnly) { + // Death evidence keeps a read after an observed exit from spawning another agent; a new chat + // also starts released, but still needs its first listing. + if ( + params.savedOnly || + (scoped?.lease?.claimStatus === 'released' && scoped.lease.deathEvidence) + ) { return answer(entry) } // Past its TTL, only the probe re-derives a held reason. The reason is served meanwhile; diff --git a/src/main/native-chat/agent-session-journal/journal-context-rewind.ts b/src/main/native-chat/agent-session-journal/journal-context-rewind.ts index dbaf544e3ef7..8db6d9c0c7f7 100644 --- a/src/main/native-chat/agent-session-journal/journal-context-rewind.ts +++ b/src/main/native-chat/agent-session-journal/journal-context-rewind.ts @@ -1,3 +1,4 @@ +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { agentJournalItemKey, agentJournalSubmissionKey @@ -33,14 +34,22 @@ export async function rewindJournalContext(input: { state.latestAcceptedTurnSequence, input.cards.pauses() ).cleared! + const queued = new Set( + [...state.submissions.values()] + .filter(isQueuedAgentJournalSubmission) + .map((entry) => agentJournalSubmissionKey(entry.clientMessageId)) + ) const removed = new Map() for (const item of state.items.values()) { - if (item.sequence > input.floor.sequence) { + if (item.sequence > input.floor.sequence && !queued.has(item.itemId)) { removed.set(item.itemId, false) } } for (const submission of state.submissions.values()) { - if ((submission.acceptedSequence ?? 0) > input.floor.sequence) { + if ( + (submission.acceptedSequence ?? 0) > input.floor.sequence && + !isQueuedAgentJournalSubmission(submission) + ) { removed.set(agentJournalSubmissionKey(submission.clientMessageId), true) } } diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts index 3a79762ee2dd..2648515c2b7a 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts @@ -2,6 +2,7 @@ import type { AgentJournalCursor, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { JournalReducerState } from './journal-reducer' import type { JournalHostDatabase } from './journal-host-database' import { replaceJournalEpoch, type JournalReplacementItem } from './journal-epoch-replacement' import type { JournalQueuePauseRestatement } from './queued-message-pause' @@ -21,6 +22,7 @@ export class JournalEpochController { database: () => JournalHostDatabase readOnly: () => boolean highestFence: () => number + state: () => JournalReducerState /** What of the live epoch's Stop and Resume a replacement restates. */ queuePauseRestatement: () => JournalQueuePauseRestatement cursor: () => AgentJournalCursor @@ -69,6 +71,7 @@ export class JournalEpochController { reason, fence, items, + previous: this.deps.state(), queuePause: this.deps.queuePauseRestatement(), now: this.deps.now, mintEpoch: this.deps.mintEpoch, 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 67927b401588..10f87ca8c16c 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 @@ -3,6 +3,9 @@ // One transaction: discard the old epoch's rows, insert the epoch row plus the // replacement items, and move the session projection. +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' + import type { AgentJournalItemBody, AgentJournalItemIdentity, @@ -13,8 +16,16 @@ import type { import { agentSessionJournalProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import type { JournalHostDatabase } from './journal-host-database' import type { JournalLoad } from './journal-open' -import { applyJournalRow, createJournalReducerState } from './journal-reducer' -import { buildJournalItemRow, journalRowBase } from './journal-row-builders' +import { + applyJournalRow, + createJournalReducerState, + type JournalReducerState +} from './journal-reducer' +import { + buildJournalItemRow, + buildJournalSubmissionRow, + journalRowBase +} from './journal-row-builders' import { buildJournalQueueReopenRow, buildJournalQueueClearRow, @@ -46,6 +57,7 @@ export function replaceJournalEpoch(input: { reason: AgentJournalEpochReason fence: number items: readonly JournalReplacementItem[] + previous?: JournalReducerState /** Restated in the new epoch, or the rewind would release cards the person stopped, or bring * back a /clear pause they already lifted. */ queuePause: JournalQueuePauseRestatement @@ -79,6 +91,29 @@ export function replaceJournalEpoch(input: { applyJournalRow(state, row) rows.push(row) } + // An accepted message that never reached the agent survives a provider history replacement. + const queued = [...(input.previous?.submissions.values() ?? [])] + .filter(isQueuedAgentJournalSubmission) + .sort((a, b) => (a.acceptedSequence ?? 0) - (b.acceptedSequence ?? 0)) + for (const submission of queued) { + const body = input.previous?.items.get( + agentJournalSubmissionKey(submission.clientMessageId) + )?.body + if (body?.kind !== 'message') { + throw new Error('journal_queued_submission_body_missing') + } + const row = buildJournalSubmissionRow({ + ...submission, + state, + body, + providerHandle: agentSessionJournalProviderHandle(input.identity), + seq: state.lastSequence + 1, + fence: input.fence, + ts: submission.submittedAt + }) + applyJournalRow(state, row) + rows.push(row) + } const { lifted, liveStop, reopened } = input.queuePause const place = () => ({ state, seq: state.lastSequence + 1, fence: input.fence, ts: input.now() }) if (lifted) { 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 a6e3aa03d189..c5306bb98696 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 @@ -1,4 +1,5 @@ import type { + AgentJournalSubmission, AgentJournalItemBody, AgentJournalItemIdentity, AgentJournalMessageItem, @@ -8,7 +9,6 @@ import type { AgentSessionJournalIdentity, AgentSessionJournalProviderHandle } from '../../../shared/agent-session-journal-types' -import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import { agentSessionJournalProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' @@ -282,7 +282,7 @@ export function buildJournalSubmissionRow(input: { handoverRecorded?: true queuedMessageId?: string origin?: 'client' | 'host' - source?: Pick + source?: AgentJournalSubmission['source'] }): JournalSubmissionRow { return { kind: 'submission', 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 d1287b378629..d789062994ab 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 @@ -73,6 +73,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal database: host.database, readOnly: host.readOnly, highestFence: () => host.state().highestFence, + state: host.state, queuePauseRestatement: () => journalQueuePauseRestatement( host.state().queuePauseMarks, 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 5c5637de2dfa..4b25d0bac070 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 @@ -495,6 +495,12 @@ describe('what a handle found on disk when it opened', () => { const leftover = journal.submissions().find((entry) => entry.clientMessageId === 'earlier') expect(journal.wroteBeforeOpen(leftover?.acceptedSequence)).toBe(true) + await journal.resolveDispatch({ + clientMessageId: 'earlier', + state: 'unknown', + reason: 'unconfirmed from an earlier host', + fence: 1 + }) // Sequences restart with an epoch, so a row accepted after it can sit below the open cursor. await journal.replaceEpochItems('handle_forked', 1, []) await journal.appendSubmission(submission('later')) 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 29a033a4e7b9..5bea0c49a235 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 @@ -50,6 +50,7 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand import { StructuredAgentArgumentsError } from '../structured-agent-arguments-error' import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { structuredAgentSessionHeldStarts } from './structured-agent-session-held-start.test-support' const CALLER = { callerKey: 'client-1' } @@ -66,8 +67,10 @@ let acquire: Mock let dispatch: Mock let adapterExtras: Partial let idleMs: number +let clock: number const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, acquisitionGeneration: `generation-${acquire.mock.calls.length}`, link: { @@ -81,12 +84,17 @@ const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spa } }) +const starts = structuredAgentSessionHeldStarts(() => host) + +/** A child whose handshake is still running: `starting` until the test settles `started`. */ +const spawnStartingChild = starts.held(spawnChild) + async function startHost(): Promise { host = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { + adapter: starts.wrap({ acquire, dispatch, closeSession: vi.fn(async () => true), @@ -95,12 +103,12 @@ async function startHost(): Promise { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined), ...adapterExtras - }, + }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, idleSweep: { intervalMs: 5, idleMs }, - now: () => NOW + now: () => clock }) } @@ -108,7 +116,9 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-accept-deliver-')) resetHostTestOperationIds() adapterExtras = {} + starts.clear() idleMs = 60 * 60_000 + clock = NOW acquire = vi.fn(spawnChild) dispatch = vi.fn(async () => ({ state: 'accepted' as const, @@ -599,15 +609,16 @@ describe('a child that exits before its message is handed over', () => { it('rejects the message with the exit reason instead of starting another child (W24)', async () => { await host.close(SESSION, 'evict') await startHost() - // Each child the loop starts dies as its start step returns: its exit, asked for on the lane - // then, lands before the handover step. + // The child exits before the host can finish its protocol open. acquire.mockImplementation(async (input) => { - const child = await spawnChild(input) + const child = await spawnStartingChild(input) + const acquisitionGeneration = `generation-${acquire.mock.calls.length}` + void starts.prove({ sessionId: SESSION, fence: input.fence, acquisitionGeneration }) void host.handleAdapterEvent({ type: 'ended', sessionId: SESSION, fence: input.fence, - acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + acquisitionGeneration, reason: 'codex app-server crashed', cause: 'unexpected-exit' }) @@ -618,8 +629,8 @@ describe('a child that exits before its message is handed over', () => { await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) expect(await submission(id)).toMatchObject({ - reason: 'Codex stopped before this message was sent.', - rejection: { kind: 'providerExited' } + reason: 'Codex stopped before it finished starting. Send your message to try again.', + rejection: { kind: 'providerStartFailed' } }) expect(acquire).toHaveBeenCalledTimes(2) expect(dispatch).not.toHaveBeenCalled() @@ -629,10 +640,7 @@ describe('a child that exits before its message is handed over', () => { describe('a start that fails while messages wait on it', () => { it('keeps one row in the words its rejected messages carry', async () => { // Both are held for a starting child that never proves its start. - acquire.mockImplementation(async (input) => ({ - ...(await spawnChild(input)), - providerChildPhase: 'starting' as const - })) + acquire.mockImplementation(spawnStartingChild) await host.close(SESSION, 'evict') await startHost() @@ -648,8 +656,7 @@ describe('a start that fails while messages wait on it', () => { failure: agentSessionFailureFact('providerStartFailed', { detail: { text: 'codex: config.toml is invalid', audience: 'person' } }), - cause: 'unexpected-exit', - startupUnproven: true + cause: 'unexpected-exit' }) await host.flushStreamedEvents(SESSION) @@ -730,10 +737,7 @@ describe('Stop withdraws what is queued', () => { adapterExtras = { closeSession } await host.close(SESSION, 'evict') await startHost() - acquire.mockImplementationOnce(async (input) => ({ - ...(await spawnChild(input)), - providerChildPhase: 'starting' as const - })) + acquire.mockImplementationOnce(spawnStartingChild) // Held for the starting child, which never proves its start. const id = await accept('hello') await eventually(() => expect(childPhase()).toBe('starting')) @@ -799,7 +803,7 @@ describe('an eviction between acceptance and handover', () => { }) it('does not stop an idle child while a message is still queued for it (W24)', async () => { - idleMs = 0 + idleMs = 1_000 const started = deferred() await host.close(SESSION, 'evict') await startHost() @@ -809,9 +813,12 @@ describe('an eviction between acceptance and handover', () => { }) const id = await accept('hello') await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) - // The idle sweep ticks every few milliseconds meanwhile. + // The idle sweep ticks every few milliseconds meanwhile, past the idle window. + clock = NOW + idleMs await new Promise((resolve) => setTimeout(resolve, 20)) + // Fresh again before publish: a start quiet for the whole window is stopped by design. + clock = NOW started.resolve() // Handed to the child it was queued for; the eviction may follow once nothing is owed. await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(1)) @@ -842,10 +849,7 @@ describe('a close that stops the child and then a wind-down step fails', () => { await startHost() acquire.mockImplementationOnce(async (input) => { await started.promise - return { - ...(await spawnChild(input)), - ...(starting ? { providerChildPhase: 'starting' as const } : {}) - } + return (starting ? spawnStartingChild : spawnChild)(input) }) const id = await accept('hello') await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) 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 9fdc5e9a72b4..c5c0d3a06f88 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,10 +1,7 @@ -// 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, quit, or the startup limit aborts it from outside the queue -// instead of waiting behind a provider that may never answer. +// Concurrent host waits keep their own cancellation handles until they settle. export class StructuredAgentSessionAcquireAborts { - private readonly inFlight = new Map() + 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 @@ -14,11 +11,14 @@ export class StructuredAgentSessionAcquireAborts { if (this.quitReason) { controller.abort(this.quitReason) } - this.inFlight.set(sessionId, controller) + const waits = this.inFlight.get(sessionId) ?? new Set() + waits.add(controller) + this.inFlight.set(sessionId, waits) return { signal: controller.signal, end: () => { - if (this.inFlight.get(sessionId) === controller) { + waits.delete(controller) + if (waits.size === 0 && this.inFlight.get(sessionId) === waits) { this.inFlight.delete(sessionId) } } @@ -27,14 +27,18 @@ export class StructuredAgentSessionAcquireAborts { /** A no-op when the session has nothing in flight. */ abort(sessionId: string, reason: string | Error): void { - this.inFlight.get(sessionId)?.abort(typeof reason === 'string' ? new Error(reason) : reason) + for (const controller of this.inFlight.get(sessionId) ?? []) { + controller.abort(typeof reason === 'string' ? new Error(reason) : 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) + for (const waits of this.inFlight.values()) { + for (const controller of waits) { + controller.abort(this.quitReason) + } } } } 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 495b7c629989..ffb50f0ef99b 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 @@ -166,7 +166,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null), now: () => NOW, onAttached: (attached) => { @@ -206,7 +205,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(RESUME_OPERATION, releasedFence), now: () => NOW + 1, onAttached: () => {} @@ -240,7 +238,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, options), now: () => NOW, recordPhase, @@ -272,7 +269,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, options), now: () => NOW, onAttached: () => {} @@ -305,7 +301,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null), now: () => NOW, onAttached: () => {} @@ -344,7 +339,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(RESUME_OPERATION, releasedFence), now: () => NOW + 1, onAttached: () => {} @@ -383,7 +377,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, { model: 'gpt-standard', fastMode: 'true' @@ -420,7 +413,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, { model: 'gpt-saved', effort: 'medium' }), now: () => NOW, onAttached: () => {} @@ -457,7 +449,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, options), now: () => NOW, onAttached: () => {} @@ -494,7 +485,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null), now: () => NOW, onAttached: () => {} @@ -590,7 +580,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(operationId, fence), now: () => NOW, onAttached: () => {} @@ -691,7 +680,6 @@ describe('the tab a create reserves', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, // Beside the fingerprinted fields, like `options`: which tab shows the chat is not which // conversation this attaches to. params: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts index d47f8650abca..90c94306db1d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts @@ -114,7 +114,6 @@ it('starts a chat whose recorded owner carries the runtime stamp', async () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} 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 a8abf52f02bb..01dc212ccdf4 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 @@ -76,8 +76,7 @@ export async function acquireOwner( spawnToken, ...(permissionMode ? { permissionMode } : {}), ...(input.eventSink ? { events: input.eventSink } : {}), - ...(input.acquireSignal ? { signal: input.acquireSignal } : {}), - optionRevision: input.optionRevision + ...(input.acquireSignal ? { signal: input.acquireSignal } : {}) }) const progress = input.onStartupAttempt?.(attempt) const acquired = await input.adapter.acquire({ @@ -129,7 +128,7 @@ export async function acquireOwner( const proved = await input.store.proveOwner({ sessionId: record.sessionId, fence, - link: acquired.link, + ...(acquired.link ? { link: acquired.link } : {}), now: input.now(), ...(options ? { options } : {}) }) 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 25e700960780..a5ec23fccc3a 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 @@ -68,6 +68,14 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return released } + open: NonNullable = (input) => { + const adapter = this.owner(input.sessionId) + if (!adapter.open) { + return Promise.reject(new Error('The provider cannot open a published child.')) + } + return adapter.open(input) + } + dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) @@ -163,8 +171,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi setOption: StructuredAgentSessionAdapter['setOption'] = (input) => this.owner(input.sessionId).setOption(input) - awaitOptionWritable = (sessionId: string): Promise => - this.liveOwnerOrNull(sessionId)?.awaitOptionWritable?.(sessionId) ?? Promise.resolve() startAnswered = (sessionId: string): boolean | undefined => this.liveOwnerOrNull(sessionId)?.startAnswered?.(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 bddcd06c9645..ae36ca266bd8 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 @@ -56,6 +56,7 @@ import type { ProviderHistoryWindow } from '../agent-session-journal/journal-sub import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' +import type { StructuredAgentSessionProviderOpen } from './structured-agent-session-provider-open-contract' /** A live options read, plus what the child listed for its account's saved catalog. The host * saves that listing and strips it before answering; an adapter whose listing reaches the @@ -109,7 +110,7 @@ export { * the process the host can probe, and the provider handle it was minted with. */ export type AgentSessionAcquisition = { process: AgentSessionProcessIdentity - link: AgentSessionProviderHandleLink + link?: AgentSessionProviderHandleLink /** Host-local identity for this exact provider child, distinct even when the durable fence is * reused by a superseding acquisition. */ acquisitionGeneration?: string @@ -204,8 +205,6 @@ export type StructuredAgentSessionEndedEvent = { acquisitionGeneration: string /** Host receipt of the child exit: the end time of a turn it interrupted. */ observedAt?: number - /** The provider ended before it finished starting, so resuming it would repeat the failure. */ - startupUnproven?: true /** The provider ended before it answered its start, so it ran nothing it was handed. */ startupUnanswered?: true } @@ -213,6 +212,8 @@ export type StructuredAgentSessionEndedEvent = { /** The child a publish-first acquire handed over has now proven its start: startup facts applied. * What it reports from here on is fact, not a catalog guess. */ export type StructuredAgentSessionStartedEvent = { + /** The handle supplied when the provider finishes starting. */ + link?: AgentSessionProviderHandleLink type: 'started' sessionId: string fence: number @@ -220,40 +221,21 @@ export type StructuredAgentSessionStartedEvent = { /** What the child proved, snapshotted by the adapter from what startup already read. The host * handles this inside the session's serialized step, so it must not ask the CLI. */ reportedOptions: AgentSessionOptionsResult['current'] - /** Saved options the child could not take; the host drops them rather than persist them. */ - restoreSkippedOptions: readonly string[] - /** Values the child showed it cannot run: a report naming the same value is not persisted. */ - retiredOptions?: Readonly> /** What the child listed at startup, saved as its account's catalog even if no view reads it. */ catalogListing?: AgentModelCatalogLiveListing - /** The attempt's `optionRevision()` as the read behind this report began: a pick the host took - * since makes it out of date. An earlier report never does, whenever the host took it. */ - optionRevision: number -} - -/** A running child showed saved options it cannot run, as a model the provider reports missing. - * The record drops them, so the next start uses the provider's own. */ -export type StructuredAgentSessionOptionsSkippedEvent = { - type: 'options-skipped' - sessionId: string - fence: number - acquisitionGeneration: string - /** Each saved value the child showed it cannot run; a record holding another value keeps it. */ - options: Readonly> } /** What a child that already proved its start reports later, such as an optional read that came * after `started`: persisted as the start's report is. Never delays a start. */ export type StructuredAgentSessionOptionsReportedEvent = Omit< StructuredAgentSessionStartedEvent, - 'type' + 'type' | 'link' > & { type: 'options-reported' } export type StructuredAgentSessionLifecycleEvent = | StructuredAgentSessionEndedEvent | StructuredAgentSessionStartedEvent | StructuredAgentSessionOptionsReportedEvent - | StructuredAgentSessionOptionsSkippedEvent /** Whether the provider child behind an acquisition has proven its start. A publish-first * acquire hands over a `starting` child, which the host gives no input until the `started` @@ -284,6 +266,8 @@ export type StructuredAgentSessionSetOptionInput = { } export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & { + /** Opens the published process under the host's startup signal. */ + open?: StructuredAgentSessionProviderOpen /** Provider-aware capability check for hosts that route more than one adapter. */ supportsCreate?(location: AgentSessionExecutionLocation, agent: string): boolean /** Provider/runtime support, kept here so remote enablement changes adapter data, not UI logic. */ @@ -314,6 +298,7 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & recoverRewind?(input: { sessionId: string fence: number + signal?: AbortSignal beforeTurnId: string }): Promise< | { ok: true; items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] } @@ -405,8 +390,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & setOption( input: StructuredAgentSessionSetOptionInput ): Promise>> - /** Resolves once a live session can take an option write, or after a bound; never rejects. */ - awaitOptionWritable?(sessionId: string): Promise /** False while the live child has not answered its start, so it has run nothing it was handed. * Absent or undefined reads as answered. */ startAnswered?(sessionId: string): boolean | undefined 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 d1116094d2d1..7b478ff10a54 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 @@ -148,7 +148,6 @@ async function attach( probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params, now: () => NOW, onAttached 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 b80135108056..b8ebf78b9370 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 @@ -100,8 +100,7 @@ function exitBeforeProof(): Promise { acquisitionGeneration: `generation-${generation}`, reason: EXIT_REASON, failure: { kind: 'providerExited', detail: { text: EXIT_REASON, audience: 'log' } }, - cause: 'unexpected-exit', - startupUnproven: true + cause: 'unexpected-exit' }) } @@ -118,6 +117,7 @@ beforeEach(async () => { resetHostTestOperationIds() generation = 0 acquire = vi.fn(async ({ fence, spawnToken }) => ({ + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -126,8 +126,7 @@ beforeEach(async () => { mintedAtFence: fence, observedAt: NOW }, - acquisitionGeneration: `generation-${++generation}`, - providerChildPhase: 'starting' as const + acquisitionGeneration: `generation-${++generation}` })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ 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 12c2f54a1793..4542a903e2b8 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 @@ -10,7 +10,8 @@ import type { import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChildIdentity } 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' @@ -34,6 +35,10 @@ export type StructuredAgentSessionAttachContext = { serialize: (sessionId: string, task: () => Promise) => Promise now: () => number publishStatus: (sessionId: string) => void + openProviderChild?: ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity + ) => void /** Joining a stop's close ends the child's record through the one exit handler. */ endExitedChild: StructuredAgentSessionLifetimeContext['endExitedChild'] wakeDelivery?: StructuredAgentSessionLifetimeContext['wakeDelivery'] 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 e4de3016b67c..bd6806730c66 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 @@ -54,6 +54,8 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' +export type AttachedOwner = 'acquired' | 'kept' | 'none' + export type AttachFlowInput = { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter @@ -72,21 +74,18 @@ export type AttachFlowInput = { onStartupAttempt?: ( attempt: StructuredAgentSessionStartupAttempt ) => StructuredAgentSessionStartupProgress - /** A `ready` child's start listing, handed over once its owner is proven. */ + /** A completed acquisition's start listing. */ onStartCatalogListing?: (listing: AgentModelCatalogLiveListing) => void - /** The conversation's option revision, which the child's reports are stamped with. */ - optionRevision: () => number /** The permission mode a start of this record launches with; absent or undefined for none. */ launchPermissionMode?: ( record: AgentSessionRecord ) => Promise - /** 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. */ + /** Publishes the journal before clients can send against the new owner. `owner` says whether + * this attach spawned the child, kept a live one, or left the chat at rest. */ onAttached: ( attached: AttachedJournal, acquisitionGeneration: string | null, - acquiredOwner: boolean, + owner: AttachedOwner, providerChildPhase: StructuredAgentSessionProviderChildPhase ) => Promise | void /** Host-owned provider sink, bound to the journal inside `onAttached`. */ @@ -136,7 +135,7 @@ export async function performAttach( let record: AgentSessionRecord let acquisitionGeneration: string | null = null - let acquiredOwner = false + let owner: AttachedOwner = 'kept' let providerChildPhase: StructuredAgentSessionProviderChildPhase = 'ready' let reservedRecord: AgentSessionRecord | null = null let unsupportedReservationSettlementAttempted = false @@ -201,14 +200,20 @@ export async function performAttach( accountHome: record.accountHome, ownerAlreadyAdmitted: agentSessionLeaseAdmitsWriter(record.lease) }) - if (!agentSessionLeaseAdmitsWriter(record.lease)) { + if (agentSessionLeaseAdmitsWriter(record.lease)) { + // Kept: re-attached to the live child. + } else if (replayed && reserved.operationRow.outcome.status === 'succeeded') { + // A create that succeeded replays its answer; the child it started ended since, so the chat + // opens at rest and the next send starts one. + owner = 'none' + } else { const acquired = await withAgentSessionCreatePhase('acquire_owner', input.recordPhase, () => acquireOwner(input, record) ) record = acquired.record acquisitionGeneration = acquired.acquisitionGeneration providerChildPhase = acquired.providerChildPhase - acquiredOwner = true + owner = 'acquired' } } catch (error) { const wording = { @@ -269,7 +274,7 @@ export async function performAttach( providerHistoryWindow }) await importAdoptedTranscript(params, attached, record, preparedTranscript.items) - await input.onAttached(attached, acquisitionGeneration, acquiredOwner, providerChildPhase) + await input.onAttached(attached, acquisitionGeneration, owner, providerChildPhase) await store.recordOperationOutcome({ callerKey: input.callerKey, operationId: params.envelope.clientOperationId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 2fdc842b335f..915fc6453374 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 @@ -205,14 +205,11 @@ async function runAttachUnderAbort( attempt.startup = startup return startupAttempts.track(sessionId, startup) }, - optionRevision: () => context.runtimeState.optionRevisions.current(sessionId), launchPermissionMode: async (record) => (await structuredAgentSessionLaunchPermission(context.deps, record)) ?? undefined, ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), openConversation: async (record) => { - const conversation = await context.openConversation(record.sessionId, { - acquisition: true - }) + const conversation = await context.openConversation(record.sessionId, { acquisition: true }) if (!conversation) { throw new Error('agent_session_identity_required') } @@ -221,19 +218,19 @@ async function runAttachUnderAbort( // The cleanup released the acquisition, which for a re-attach is the live child itself. onAcquisitionReleased: (cause, verdict) => endStructuredAgentSessionReleasedChild(context, sessionId, cause, verdict), - onAttached: async (attached, acquisitionGeneration, acquiredOwner, providerChildPhase) => { + onAttached: async (attached, acquisitionGeneration, owner, providerChildPhase) => { const fence = structuredAgentSessionConversationFence(context.deps.store, sessionId) const current = context.sessions.get(sessionId)?.child ?? null - const startedFor = acquiredOwner ? options.startedFor : current?.startedFor + const startedFor = owner === 'acquired' ? options.startedFor : current?.startedFor // A re-attach to a live child keeps the mode that child was launched with. - const permissionMode = acquiredOwner - ? attempt.startup?.permissionMode - : current?.permissionMode + const permissionMode = + owner === 'acquired' ? attempt.startup?.permissionMode : current?.permissionMode // A re-attach to a live child keeps the sink that child already writes through. - const eventSink = acquiredOwner - ? attemptSink - : (context.runtimeState.currentEventSink(sessionId) ?? attemptSink) - if (acquiredOwner) { + const eventSink = + owner === 'acquired' + ? attemptSink + : (context.runtimeState.currentEventSink(sessionId) ?? attemptSink) + if (owner === 'acquired') { // Before the drain: the buffered events are the new child's, never a stale row's. await settleStaleStructuredAgentSessionState({ journal: attached.journal, @@ -248,25 +245,32 @@ async function runAttachUnderAbort( await bindAndDrain(eventSink, attached.journal, fence, (activity) => context.subscribers.publish(sessionId, attached.journal, activity) ) - attempt.candidate = { - sink: eventSink, - child: { - generation: acquisitionGeneration ?? current?.generation ?? null, - fence, - // A re-attach to a live child keeps what that child already proved, and its cause. - phase: acquiredOwner ? providerChildPhase : (current?.phase ?? 'ready'), - ...(startedFor === undefined ? {} : { startedFor }), - ...(permissionMode === undefined ? {} : { permissionMode }) + // A replayed create over a chat at rest has no child to index. + if (owner !== 'none') { + attempt.candidate = { + sink: eventSink, + child: { + generation: acquisitionGeneration ?? current?.generation ?? null, + fence, + // An acquire reports its phase; a re-attach keeps what its child already proved. + phase: owner === 'acquired' ? providerChildPhase : (current?.phase ?? 'ready'), + ...(startedFor === undefined ? {} : { startedFor }), + ...(permissionMode === undefined ? {} : { permissionMode }) + } } } - await recoverStructuredRewind( - context.deps, - sessionId, - attached.journal, - fence, - context.deps.adapter, - context.now - ) + // Only a proven child has the protocol session recovery asks; a starting one recovers in + // its `started` step, before it is handed anything. + if (attempt.candidate?.child.phase === 'ready') { + await recoverStructuredRewind( + context.deps, + sessionId, + attached.journal, + fence, + context.deps.adapter, + context.now + ) + } if (fenceBefore !== null && fence !== fenceBefore) { context.subscribers.snapshot(sessionId, attached.journal, fence) } else { @@ -282,9 +286,10 @@ async function runAttachUnderAbort( indexProviderChild(conversation, candidate.child) if (attempt.startup) { startupAttempts.published(sessionId, attempt.startup.attemptId, candidate.child) - // Only an acquire proves a start; a re-attach to a live child proved nothing. if (candidate.child.phase === 'ready') { noteStructuredAgentSessionProviderStarted(context.deps, sessionId) + } else { + context.openProviderChild?.(sessionId, candidate.child) } } context.publishStatus?.(sessionId) 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 524b4fd4d3f2..c799ba1a161b 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 @@ -26,13 +26,14 @@ export async function joinStructuredAgentSessionChildClose( child: StructuredAgentSessionProviderChild ): Promise { // Read before the close: once the child is gone the provider can no longer say. - const unanswered = context.deps.adapter.startAnswered?.(sessionId) === false + const unanswered = child.phase === 'starting' if (!(await closeProviderRoot(context, sessionId))) { return 'unverifiable' } await context.endExitedChild(sessionId, child, { expected: true, reason: 'closed by Orca', + ...(child.close?.failure ? { failure: child.close.failure } : {}), ...(unanswered ? { startupUnanswered: true } : {}) }) context.restartWitness?.stopped(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 index 694268a1f8bf..4181bf1a09ce 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 @@ -40,7 +40,6 @@ export type StructuredAgentSessionChildExit = { failure?: SubmissionRejectionFact /** Host receipt of the exit: the end time of a turn it interrupted. */ observedAt?: number - startupUnproven?: true startupUnanswered?: true } @@ -61,6 +60,7 @@ export type StructuredAgentSessionChildExitContext< wakeDelivery?: (sessionId: string) => void /** An ended child's start, if still open, has nothing left to time. */ startupAttempts?: Pick + abortOpen?: (sessionId: string) => void serialize: (sessionId: string, task: () => Promise) => Promise now: () => number logger: StructuredAgentSessionLogger @@ -95,7 +95,6 @@ export function settleStructuredAgentSessionChildExit< reason: event.reason, ...(event.failure ? { failure: event.failure } : {}), ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), - ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}), ...(event.startupUnanswered ? { startupUnanswered: event.startupUnanswered } : {}) }) }) @@ -118,15 +117,16 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< 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 exitedDuringStartup = child.phase === 'starting' + if (child.phase === 'starting') { + context.abortOpen?.(sessionId) + } // A close during startup settles what the child was handed, never echoed, by who asked: a // person's Stop stops it; the host's stop fails the start, as an exit of its own would. const startClose = expected && exitedDuringStartup ? close?.cause : undefined const startFailure = expected ? startClose === 'host-stop' - ? agentSessionFailureFact('hostStopped') + ? (exit.failure ?? close?.failure ?? agentSessionFailureFact('hostStopped')) : undefined : exit.failure // A send the child was handed and never echoed cannot have run when a person's Stop ended its @@ -153,7 +153,7 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< // 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 } : {}), + ...(startFailure ? { failure: startFailure } : {}), duringStartup: exitedDuringStartup, // The adapter publishes an exit only once it saw the root go, first-hand or proven. rootGone: true, 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 861e7fc7b9f4..d7a47351dc95 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 @@ -12,8 +12,7 @@ import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-s import { fakeClaude, PROVIDER_SESSION_ID, - type FakeConnection, - claudeStartupSettled + 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' @@ -93,7 +92,7 @@ beforeEach(async () => { }) ) expect(attached).toMatchObject({ ok: true }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) }) afterEach(async () => { 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 33fe1fe57511..c5b4905be176 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 @@ -5,15 +5,14 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, expect, it } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID, - claudeStartupSettled + PROVIDER_SESSION_ID } 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' @@ -85,7 +84,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts index 347de6cf6643..a74957b657fa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts @@ -131,12 +131,10 @@ async function readsWorking(): Promise { ) } -/** Whether the CLI was written `hello`, once the delivery loop has settled what it does with it. */ -async function helloWritten(): Promise { - await vi.waitFor(() => - expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) - ) - return claude.connections[0]!.sent.some((message) => JSON.stringify(message).includes('hello')) +async function expectHeld(id: string): Promise { + expect(await submission(id)).toMatchObject({ dispatchState: 'pending' }) + expect((await submission(id))?.handedOverAt).toBeUndefined() + expect(claude.connections[0]!.sent).toEqual([]) } it('holds the message from a start that never answers, and a Stop ends it: stopped, nothing working', async () => { @@ -150,7 +148,7 @@ it('holds the message from a start that never answers, and a Stop ends it: stopp const [connection] = claude.connections // Accepted, and held while initialize is still unanswered. - expect(await helloWritten()).toBe(false) + await expectHeld(id) expect(connection!.calls).toEqual([{ subtype: 'initialize' }]) expect(host['sessions'].get(SESSION)?.child?.phase).toBe('starting') expect(await readsWorking()).toBe(true) @@ -182,7 +180,9 @@ async function sendHello(written: boolean): Promise { if (!sent.ok) { throw new Error('send refused') } - await vi.waitFor(async () => expect(await helloWritten()).toBe(written)) + await (written + ? vi.waitFor(() => expect(claude.connections[0]!.sent).toHaveLength(1)) + : expectHeld(sent.value.clientMessageId)) return sent.value.clientMessageId } @@ -215,7 +215,7 @@ it.each(['user-close', 'evict'] as const)( // doubt, as for any running child. it('leaves a message in doubt when the start answered and the chat closes before the echo', async () => { answerInitialize() - await adapter['sessions'].get(SESSION)?.startup.settled + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) const id = await sendHello(true) await host.close(SESSION, 'user-close') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts index 256db2f8de37..62f983e9ddd8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts @@ -15,7 +15,6 @@ import { nativeChatRowsInDrawOrder } from '../../../shared/native-chat-turn-grou import { nativeChatTurnMembership } from '../../../shared/native-chat-turn-membership' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { - claudeStartupSettled, fakeClaude, PROVIDER_SESSION_ID, type FakeConnection @@ -98,7 +97,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) await Promise.all(lifecycle) }) 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 88e495b5dbdf..6b50d0bc03f1 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 @@ -1,7 +1,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { afterEach, beforeEach, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -12,13 +12,14 @@ import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, hostTestAttachParams, + hostTestMessage, hostTestOperationId, resetHostTestOperationIds } 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' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-claude' } const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' @@ -30,8 +31,11 @@ let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let acquire: Mock let activeModel: string -let optionWritable: Promise +let activeEffort: string | null +let dispatch: Mock +let order: string[] let setOption: Mock +let releaseOpen: () => void function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { return { @@ -47,14 +51,25 @@ function envelope(method: string, fields: Record): AgentSession } function adapter(): StructuredAgentSessionAdapter { - setOption = vi.fn(async ({ value }) => { - activeModel = value - return { model: value } + setOption = vi.fn(async ({ key, value }) => { + order.push(`${key}=${value}`) + if (key === 'model') { + activeModel = value + } else if (key === 'effort') { + activeEffort = value + } + return { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) } + }) + dispatch = vi.fn(async () => { + order.push('dispatch') + return { state: 'admitted' as const } }) acquire = vi.fn(async ({ fence, spawnToken, options }) => { activeModel = options?.model ?? DEFAULT_MODEL return { + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4200, processStartTimeMs: NOW, spawnToken }, + acquisitionGeneration: 'generation-1', link: { linkId: `claude-native-${fence}`, handle: claudeProviderHandle(CLAUDE_SESSION, 'native-leaf'), @@ -65,12 +80,18 @@ function adapter(): StructuredAgentSessionAdapter { } }) return { + open: () => + new Promise((resolve) => { + releaseOpen = () => + resolve({ + reportedOptions: { model: DEFAULT_MODEL } + }) + }), acquire, - dispatch: vi.fn(), + dispatch, cancelTurn: vi.fn(async () => ({ cancelled: true })), answerPrompt: vi.fn(async () => undefined), setOption, - awaitOptionWritable: () => optionWritable, readOptions: vi.fn(async () => ({ current: { model: activeModel }, models: [] })), closeSession: vi.fn(async () => { activeModel = DEFAULT_MODEL @@ -83,10 +104,12 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-claude-options-')) resetHostTestOperationIds() activeModel = DEFAULT_MODEL - optionWritable = Promise.resolve() + activeEffort = null + order = [] + releaseOpen = () => {} store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), @@ -114,22 +137,51 @@ afterEach(async () => { await rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }) }) -describe('Claude structured session options', () => { - it('queues a pick made while the provider starts only once it can take it', async () => { - let land = (): void => {} - optionWritable = new Promise((resolve) => { - land = resolve - }) - const fields = { key: 'model', value: PICKED_MODEL } - const picked = host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', fields), - ...fields - }) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(setOption).not.toHaveBeenCalled() +it('applies model and effort picks on a running Claude chat to the next message', async () => { + releaseOpen() + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) + for (const [key, value] of [ + ['model', PICKED_MODEL], + ['effort', 'high'] + ]) { + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', { key, value }), + key, + value + }) + ).toMatchObject({ ok: true }) + } + const body = hostTestMessage('with the new choices') + dispatch.mockImplementationOnce(async () => { + expect(activeModel).toBe(PICKED_MODEL) + expect(setOption).toHaveBeenCalledWith( + expect.objectContaining({ key: 'effort', value: 'high' }) + ) + return { state: 'admitted' as const } + }) + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(order).toEqual(['model=opus', 'effort=high']) +}) - land() - expect(await picked).toMatchObject({ ok: true, value: { options: { model: PICKED_MODEL } } }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL }) +it('saves a waiting startup pick when Stop ends the child before it is ready', async () => { + const fields = { key: 'model', value: PICKED_MODEL } + const picked = host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', fields), + ...fields }) + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + try { + expect( + await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + ).toMatchObject({ ok: true }) + expect(await picked).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.options?.model).toBe(PICKED_MODEL) + expect(setOption).not.toHaveBeenCalled() + } finally { + releaseOpen() + } }) 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 cf7127966fe5..e49076f4882b 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 @@ -14,8 +14,7 @@ import { projectStructuredAgentSessionStatus } from '../../../shared/structured- import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID, - claudeStartupSettled + PROVIDER_SESSION_ID } 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' @@ -103,7 +102,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) await Promise.all(lifecycle) }) 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 726396adc21d..7b00385e7734 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 @@ -133,7 +133,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter['sessions'].get(SESSION)?.startup.settled + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) await Promise.all(lifecycle) }) 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 00a0644136c3..14adfb0f2b6b 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 @@ -13,8 +13,7 @@ import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-s import { fakeClaude, PROVIDER_SESSION_ID, - type FakeConnection, - claudeStartupSettled + 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' @@ -103,7 +102,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) await Promise.all(lifecycle) }) @@ -452,22 +451,16 @@ it('rejects a message whose start meets a close still unverifiable, and starts n expect(scopes()).toContain('provider-close-unproven') }) -it('refuses an option change while the close stays unverifiable, never writing to the old child', async () => { +it('saves an option change while the close stays unverifiable without 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) + await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) + expect(connection.closeCount).toBe(1) 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. + // The pick remains saved for a later start while the old child is still unverifiable. await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) - expect(child()).toBeNull() + expect(child()?.close).toBeDefined() expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'claude-opus-5' }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts index 7b005ffb8176..d83cd52c6326 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts @@ -12,7 +12,6 @@ import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-a import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { - claudeStartupSettled, fakeClaude, PROVIDER_SESSION_ID, type FakeConnection @@ -109,7 +108,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) await Promise.all(lifecycle) await host.subscribe({ id: 'stop-note-live', 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 2e1ac52b45fb..7b8e12e67524 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 @@ -14,8 +14,7 @@ import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-s import { fakeClaude, PROVIDER_SESSION_ID, - type FakeConnection, - claudeStartupSettled + 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' @@ -88,7 +87,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts index 3ee2b83a4123..af0a8848b13f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts @@ -70,7 +70,11 @@ beforeEach(async () => { adapter = adapterFor(codex, launch, [], { onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), onEvent: (event) => { - if (event.type === 'ended' && 'cause' in event) { + if ( + event.type === 'started' || + event.type === 'options-reported' || + (event.type === 'ended' && 'cause' in event) + ) { void host.handleAdapterEvent(event) } } 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 f6d7d6d4be7e..5b4c829392e1 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 @@ -65,7 +65,11 @@ beforeEach(async () => { const adapter = adapterFor(codex, launch, [], { onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), onEvent: (event) => { - if (event.type === 'ended' && 'cause' in event) { + if ( + event.type === 'started' || + event.type === 'options-reported' || + (event.type === 'ended' && 'cause' in event) + ) { void host.handleAdapterEvent(event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts index ef007e03d6be..c1d70a17f5c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts @@ -143,7 +143,7 @@ describe('restart resume around /compact (B14)', () => { function markerFor(items: AgentJournalRenderItem[], tasks: boolean) { return structuredAgentSessionWorkingAtStop({ sessionId: SESSION, - session: { journal: journal(items), child: { fence: 1 } }, + session: { journal: journal(items), child: { fence: 1, phase: 'ready' as const } }, getRecord: () => record(), childWork: () => tasks ? [childRecord({ id: 'task-a', kind: 'agent', description: 'Review' })] : [], 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 4e6b575ef1f1..53dcb9d420f7 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 @@ -224,10 +224,7 @@ describe('a start that never finishes (P2-15)', () => { it('is stopped by the sweep, and the message held for it carries its one row and rejection', async () => { const stopReason = 'Codex never finished starting, so Orca stopped it.' const spawn = rig.adapter.acquire.getMockImplementation()! - rig.adapter.acquire.mockImplementationOnce(async (input) => ({ - ...(await spawn(input)), - providerChildPhase: 'starting' as const - })) + rig.adapter.acquire.mockImplementationOnce(rig.heldStart(spawn)) // Held for the starting child, which never proves its start. const reader = collectSubscriber() const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) 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 431ca9e761de..424107e39c93 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 @@ -21,12 +21,21 @@ import { type StructuredAgentSessionLifetimeContext, type StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import type { + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChildIdentity +} from './structured-agent-session-host-types' import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger' import { sameProviderChild } from './structured-agent-session-provider-child' import type { StructuredAgentSessionExpiredStartup } from './structured-agent-session-startup-attempt' +import { + providerStartupFailureFact, + structuredAgentSessionStartFailure +} from './structured-agent-session-failure-text' +import { recordStructuredAgentSessionStartFailure } from './structured-agent-session-start-failure-row' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' export type StructuredAgentSessionConversationLifetime = ReturnType< typeof createStructuredAgentSessionConversationLifetime @@ -71,9 +80,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { sessionId, atRest ) - if (closed) { - host.context().runtimeState.optionRevisions.forget(sessionId) - } return closed } @@ -103,33 +109,73 @@ export function createStructuredAgentSessionConversationLifetime(host: { ...deps().idleSweep }) + const stopStartingChild = ( + sessionId: string, + expired: StructuredAgentSessionProviderChildIdentity | null + ): void => { + void serialize(sessionId, async () => { + const child = sessions.get(sessionId)?.child + if (!expired || !child || child.phase !== 'starting' || !sameProviderChild(child, expired)) { + return + } + await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { + cause: 'host-stop' + }) + }).catch((error: unknown) => + deps().logger.warn('stopping an agent that could not finish starting failed', { + scope: 'startup-limit', + sessionId, + error + }) + ) + } return { idleSweep, stopAgent, /** A host stop of the child the expired attempt published, if it is still that one and still * starting: its end fails what is queued, as the idle sweep's stop of a start does. */ - expireStartup: ({ sessionId, child: expired }: StructuredAgentSessionExpiredStartup): void => { - void serialize(sessionId, async () => { - const child = sessions.get(sessionId)?.child - if ( - !expired || - !child || - child.phase !== 'starting' || - !sameProviderChild(child, expired) - ) { + expireStartup: ({ sessionId, child }: StructuredAgentSessionExpiredStartup): void => + stopStartingChild(sessionId, child), + /** A start that cannot finish ends as one past its limit does, outside the caller's step. */ + stopStartingChild, + failStartingChild: ( + sessionId: string, + failed: StructuredAgentSessionProviderChildIdentity, + error: unknown + ): Promise => + serialize(sessionId, async () => { + const session = sessions.get(sessionId) + const child = session?.child + if (!session || !child || child.close || !sameProviderChild(child, failed)) { return } - await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { - cause: 'host-stop' - }) - }).catch((error: unknown) => - deps().logger.warn('stopping an agent past its startup limit failed', { - scope: 'startup-limit', - sessionId, - error - }) - ) - }, + const failure = providerStartupFailureFact(error) + try { + await recordStructuredAgentSessionStartFailure( + { journal: session.journal, fence: child.fence }, + { + startKey: child.generation, + ...structuredAgentSessionStartFailure( + { failure }, + structuredAgentSessionFailureWordsContext(deps().store.getRecord(sessionId)) + ) + } + ).catch((writeError: unknown) => + deps().logger.warn('recording a failed provider open failed', { + scope: 'provider-open-failure', + sessionId, + error: writeError + }) + ) + await stopAgent(sessionId, { + cause: 'host-stop', + reason: error instanceof Error ? error.message : String(error), + failure + }) + } finally { + host.context().runtimeState.startupAttempts.childEnded(sessionId, child) + } + }), /** Quit has begun: nothing opens a conversation or sweeps one after this. */ dispose: (): void => { disposed = true 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 e295c44302a0..fe2f6450dbd3 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 @@ -30,9 +30,10 @@ 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' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' import { THREAD_ID, + acquireReadyCodexForTest, adapterFor, answerWithOpenedTurn, fakeCodex, @@ -76,7 +77,7 @@ beforeEach(async () => { log = recordingStructuredAgentSessionLogger() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, + agents: claudeAndCodexDeclared(), logger: log.logger, store, adapter: { @@ -203,7 +204,7 @@ describe('a Stop that names no turn', () => { const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog: new AgentModelCatalogStore() }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-catalog', @@ -215,6 +216,10 @@ describe('a Stop that names no turn', () => { cancelTurn.mockImplementation((input) => adapter.cancelTurn(input)) setOption.mockImplementation((input) => adapter.setOption(input)) + const listing = adapter.readOptions({ sessionId: SESSION, fence: 1 }) + const catalogReads = codex.connections[0].calls.filter( + (call) => call.method === 'model/list' + ).length const first = send('first send') await first.result await eventually(() => @@ -222,10 +227,10 @@ describe('a Stop that names no turn', () => { ) expect(await pickOption('model', 'gpt-next')).toMatchObject({ ok: true, - value: { options: { model: 'gpt-next', serviceTier: 'priority' } } + value: { options: { model: 'gpt-next' } } }) expect(codex.connections[0].calls.filter((call) => call.method === 'model/list')).toHaveLength( - 1 + catalogReads ) const followUp = send('follow-up') @@ -248,8 +253,9 @@ describe('a Stop that names no turn', () => { ) expect( codex.connections[0].calls.filter((call) => call.method === 'turn/start')[1]?.params - ).toMatchObject({ model: 'gpt-next', serviceTier: 'priority' }) + ).toMatchObject({ serviceTier: 'priority' }) pending.resolve({ data: [], nextCursor: null }) + await expect(listing).rejects.toThrow('no available models') }) // Codex can abort a turn before it starts: it answers the interrupt, then ends the turn it never @@ -275,7 +281,7 @@ describe('a Stop that names no turn', () => { return {} } const adapter = adapterFor(codex, { codexHome: '/codex/home' }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-unstarted', @@ -317,7 +323,7 @@ describe('a Stop that names no turn', () => { return {} } const adapter = adapterFor(codex, { codexHome: '/codex/home' }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-unstarted-item', @@ -346,7 +352,7 @@ describe('a Stop that names no turn', () => { const codex = fakeCodex() codex.routes['turn/start'] = () => ({ turn: { id: 'turn-1' } }) const adapter = adapterFor(codex, { codexHome: '/codex/home' }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-unstarted-failed', @@ -369,11 +375,11 @@ describe('a Stop that names no turn', () => { ]) }) - it('fails a turn Codex refuses for a model picked before the list, then sends again', async () => { + it('records a provider-refused turn, then sends again without changing saved picks', async () => { const pending = Promise.withResolvers() const codex = fakeCodex({ 'model/list': () => pending.promise }) codex.routes['turn/start'] = (params) => { - if (params?.model === 'gpt-missing') { + if (codex.connections[0].calls.filter((call) => call.method === 'turn/start').length === 1) { throw new CodexAppServerRequestError( 'turn/start', -32602, @@ -386,7 +392,11 @@ describe('a Stop that names no turn', () => { const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog: new AgentModelCatalogStore() }) - await adapter.acquire({ identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-bad' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor(SESSION), + fence: 1, + spawnToken: 'spawn-bad' + }) dispatch.mockImplementation((input) => adapter.dispatch(input)) setOption.mockImplementation((input) => adapter.setOption(input)) @@ -409,9 +419,7 @@ describe('a Stop that names no turn', () => { codex.connections[0].calls.filter((call) => call.method === 'turn/start') ).toHaveLength(2) ) - expect( - codex.connections[0].calls.filter((call) => call.method === 'turn/start')[1]?.params - ).toMatchObject({ model: 'gpt-live' }) + expect(store.getRecord(SESSION)?.options?.model).toBe('gpt-live') await eventually(async () => expect((await submission(retried.id))?.dispatchState).not.toBe('rejected') ) 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 e10d215dc80a..e393cbf0d157 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 @@ -11,6 +11,7 @@ import { AGENT_SESSION_OPERATION_FUTURE_SKEW_MS } from '../../../shared/agent-session-host-authority' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { openCodexAppServerConnection } from '../../codex/codex-app-server-connection' import { adapterFor, fakeCodex } from '../../codex/codex-structured-session-adapter-fixture' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -22,6 +23,8 @@ import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, hostTestAttachParams, + hostTestMessage, + hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' @@ -64,17 +67,23 @@ function openStore(generation: HostGeneration): Promise return openTestAgentSessionRecordStore(generationRoot(generation)) } -/** A Codex adapter whose child spawns, reports its pid the way the real connection does, and then - * never answers the thread handshake: the host dies in that window. */ +/** A Codex adapter whose child spawns, reports its pid (the fake's `CHILD_PID`) the way the real + * connection does, and then never answers the thread handshake: the host dies in that window. */ function adapterThatNeverFinishesStarting(): StructuredAgentSessionAdapter { const codex = fakeCodex({ 'thread/start': () => new Promise(() => {}), 'thread/resume': () => new Promise(() => {}) }) + return Object.assign(adapterFor(codex), { supportsCreate: () => true }) +} + +/** A Codex adapter whose child spawns and reports its pid, and whose acquire never answers: the + * host dies with the child recorded and the create still unanswered. */ +function adapterThatNeverAnswersItsSpawn(): StructuredAgentSessionAdapter { + const codex = fakeCodex() const openConnection: typeof openCodexAppServerConnection = async (launch, handlers = {}) => { - const connection = await codex.openConnection(launch, handlers) - await handlers.onSpawned?.(CHILD_PID) - return connection + await codex.openConnection(launch, handlers) + return new Promise(() => {}) } return Object.assign(adapterFor({ ...codex, openConnection }), { supportsCreate: () => true }) } @@ -111,13 +120,15 @@ describe('a host that dies while its Codex child is starting', () => { const first = await openStore('dying') const dying = host('dying', first, adapterThatNeverFinishesStarting()) void dying.attach(CALLER, hostTestAttachParams(null)).catch(() => {}) - await vi.waitFor(() => expect(first.getRecord(SESSION)?.lease.ownerProcess).toBeTruthy()) - // Durable before the handshake returned: the only record the next host will have. - expect(first.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'reserved', - handoffStage: 'new-owner-proving', - ownerProcess: { pid: CHILD_PID, spawnToken: 'spawn-a' } - }) + // Durable before the handshake returned, which never does: the only record the next host will + // have. The child is published at spawn, so its lease is already live while it starts. + await vi.waitFor(() => + expect(first.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + handoffStage: null, + ownerProcess: { pid: CHILD_PID, spawnToken: 'spawn-a' } + }) + ) // The relaunch. The orphan is still alive until something stops it. let orphanAlive = true @@ -167,10 +178,10 @@ describe('a create replayed after the host that ran it died', () => { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + 60_000 it.each([ - ['its child was recorded', adapterThatNeverFinishesStarting, 0], + ['its child was recorded', adapterThatNeverAnswersItsSpawn, 0], [ 'its child was recorded, and its operation row has since expired', - adapterThatNeverFinishesStarting, + adapterThatNeverAnswersItsSpawn, PAST_OPERATION_EXPIRY ], ['nothing was recorded beyond the reservation', adapterThatNeverSpawns, 0], @@ -238,3 +249,54 @@ describe('a create replayed after the host that ran it died', () => { expect(restarted.connections).toHaveLength(1) }) }) + +// The dying host answered the create at spawn, but the answer was lost on its way to the client. +describe('a create answered before the host that ran it died', () => { + it('replays that answer with the chat at rest, and the next send starts the agent', async () => { + const params = hostTestAttachParams(null) + const first = await openStore('dying') + const dying = host('dying', first, adapterThatNeverFinishesStarting()) + await expect(dying.attach(CALLER, params)).resolves.toMatchObject({ ok: true }) + + const restarted = fakeCodex() + await crash(first) + const store = await openStore('relaunched') + const relaunched = host( + 'relaunched', + store, + Object.assign(adapterFor(restarted), { supportsCreate: () => true }), + { mintSpawnToken: () => 'spawn-b', probeOwner: async () => ({ outcome: 'pid-absent' }) } + ) + await relaunched.restoreReadableSessions() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) + + // Before, `ownerUnproven`: the replay tried to start an agent from a reservation long gone. + await expect(relaunched.attach(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { sessionId: SESSION, fence: 2 } + }) + expect(restarted.connections).toHaveLength(0) + + const body = hostTestMessage('after the lost answer') + const sent = await relaunched.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: 2, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(restarted.connections).toHaveLength(1)) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + ownerProcess: { spawnToken: 'spawn-b' } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts index 2bee381140a5..e6e05f91d6cd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts @@ -194,10 +194,11 @@ it('holds the created first message until the child proves its start, then hands const first = firstMessage() const spawn = rig.adapter.acquire.getMockImplementation()! let generation = '' + const holdStart = rig.heldStart(spawn) rig.adapter.acquire.mockImplementationOnce(async (input) => { - const child = await spawn(input) + const child = await holdStart(input) generation = child.acquisitionGeneration ?? '' - return { ...child, providerChildPhase: 'starting' as const } + return child }) expect( await rig.host.create(CALLER, createTestParams(first), { firstMessage: first }) @@ -214,9 +215,7 @@ it('holds the created first message until the child proves its start, then hands sessionId: SESSION, fence: rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, acquisitionGeneration: generation, - reportedOptions: { model: 'gpt-live' }, - restoreSkippedOptions: [], - optionRevision: rig.host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + reportedOptions: { model: 'gpt-live' } }) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) @@ -229,10 +228,7 @@ it('holds the created first message until the child proves its start, then hands it('Stop while the child proves its start withdraws the created first message once, unsent', async () => { const first = firstMessage() const spawn = rig.adapter.acquire.getMockImplementation()! - rig.adapter.acquire.mockImplementationOnce(async (input) => ({ - ...(await spawn(input)), - providerChildPhase: 'starting' as const - })) + rig.adapter.acquire.mockImplementationOnce(rig.heldStart(spawn)) expect( await rig.host.create(CALLER, createTestParams(first), { firstMessage: first }) ).toMatchObject({ ok: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts index 61f596359165..9bd311b3852e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts @@ -32,9 +32,17 @@ describe('what a child end means for the messages queued behind it', () => { }) it.each([false, true])( - 'is a start Orca stopped after a host stop (during startup: %s)', + 'keeps a recorded failure after a host stop (during startup: %s)', (starting) => { expect(structuredAgentSessionEndedChildFailure(ended('host-stop', starting))).toEqual({ + failure: EXIT_FAILURE + }) + expect( + structuredAgentSessionEndedChildFailure({ + ...ended('host-stop', starting), + failure: undefined + }) + ).toEqual({ failure: { kind: 'hostStopped' } }) } 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 1858eaeddf8d..710b758a97a3 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 @@ -12,6 +12,7 @@ // earlier host process left queued is never handed over: the open, or this loop's first step, // settles it first (`journal-unsent-send-hold.ts`). +import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' @@ -30,17 +31,20 @@ import type { } from './structured-agent-session-host-types' import { oldestQueuedSubmission, - recordStructuredAgentSessionStartFailure + startThatFailedWhileQueued, + recordStructuredAgentSessionStartFailure, + refusedStructuredAgentSessionStart } from './structured-agent-session-start-failure-row' -import { failedProviderChildStart } from './structured-agent-session-provider-child' import { handOverSubmission } from './structured-agent-session-turns' import { structuredAgentSessionNextHandover } from './structured-agent-session-opening-send' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { StructuredAgentSessionStartReadiness } from './structured-agent-session-startup-attempt' import { holdRestartedStructuredAgentSessionSends } from './structured-agent-session-host-lifetime' import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' export type StructuredAgentSessionDeliveryLoopDeps = { + deliveryOrder: StructuredAgentSessionDeliveryOrder sessions: ReadonlyMap adapter: StructuredAgentSessionAdapter agents: StructuredAgentRegistry @@ -53,6 +57,10 @@ export type StructuredAgentSessionDeliveryLoopDeps = { sessionId: string, startedFor: string ) => Promise + readiness: ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity + ) => StructuredAgentSessionStartReadiness | null /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ conversationFence: (sessionId: string) => number /** Settles queued messages as a completed close of the chat does; false when that failed. */ @@ -77,7 +85,12 @@ type RefusedStart = Extract type Prepared = | 'stop' | RefusedStart - | { ok: true; awaited: StructuredAgentSessionProviderChildIdentity | null } + | { + ok: true + awaited: StructuredAgentSessionProviderChildIdentity | null + readiness: StructuredAgentSessionStartReadiness | null + before: Promise + } /** A failed start before it is worded; `fail` words it once, through the one wording point. */ type StartFailure = { startKey: string | null; cause: StructuredAgentSessionStartFailureCause } @@ -96,6 +109,7 @@ export class StructuredAgentSessionDeliveryLoop { /** Quit: no step after this one starts a child or hands a message over. */ dispose(): void { this.disposed = true + this.deps.deliveryOrder.dispose() } /** From inside the session's serialize, after a message was accepted or the conversation @@ -105,6 +119,10 @@ export class StructuredAgentSessionDeliveryLoop { if (this.disposed) { return Promise.resolve() } + this.deps.deliveryOrder.reconcile( + sessionId, + this.deps.sessions.get(sessionId)?.journal.submissions() ?? [] + ) if (this.running.has(sessionId)) { return this.runs.get(sessionId) ?? Promise.resolve() } @@ -138,8 +156,12 @@ export class StructuredAgentSessionDeliveryLoop { ) return } + if (prepared.readiness) { + await prepared.readiness.wait() + } + await prepared.before const handed = await this.deps.serialize(sessionId, () => - this.handOver(sessionId, prepared.awaited) + this.handOver(sessionId, prepared.awaited, prepared.readiness !== null) ) if (handed === 'stop') { return @@ -209,13 +231,16 @@ export class StructuredAgentSessionDeliveryLoop { // The child this run waits on; handover checks it is still the one there. return { ok: true, - awaited: child ? { generation: child.generation, fence: child.fence } : null + awaited: child ? { generation: child.generation, fence: child.fence } : null, + readiness: child?.phase === 'starting' ? this.deps.readiness(sessionId, child) : null, + before: this.deps.deliveryOrder.beforeSend(sessionId, oldest.clientMessageId) } } private async handOver( sessionId: string, - awaited: StructuredAgentSessionProviderChildIdentity | null + awaited: StructuredAgentSessionProviderChildIdentity | null, + waited: boolean ): Promise { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { @@ -247,9 +272,7 @@ export class StructuredAgentSessionDeliveryLoop { // Never steer into a turn a person's Stop is ending: the message runs after it, as its own // turn, and the turn's end is a commit that wakes the loop again. Read here, at the handover, // because a Stop can take the lane between the step that judged the send and this one. - // Nor hand anything to a child still proving its start: nothing is written until it is - // `ready`, whose settlement wakes the loop, so a start that fails leaves the message unsent. - if (this.deps.stopping(sessionId) || awaitedChild.phase === 'starting') { + if (this.deps.stopping(sessionId) || awaitedChild.phase !== 'ready') { return this.stop(sessionId) } // None while a turn is still opening: joining it would record the message ahead of it. @@ -257,6 +280,16 @@ export class StructuredAgentSessionDeliveryLoop { if (!next) { return this.stop(sessionId) } + if (waited) { + // Re-check launch permission only after proving the awaited child still exists. + const ready = await this.deps.ensureProviderChild(sessionId, next.clientMessageId) + if (!ready.ok) { + return this.fail(sessionId, this.refusedStart(sessionId, ready)) + } + if (session.child !== awaitedChild) { + return 'continue' + } + } await handOverSubmission( { sessionId, @@ -275,24 +308,8 @@ export class StructuredAgentSessionDeliveryLoop { } /** A start the session refused, as the failure every queued message it was for is rejected with. */ - private refusedStart( - sessionId: string, - { refusal, diagnostic, argumentProblem, expired }: RefusedStart - ): StartFailure { - if (expired) { - return { startKey: null, cause: { failure: agentSessionFailureFact('hostStopped') } } - } - // 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 } : {}), - ...(argumentProblem ? { argumentProblem } : {}), - ...(newSession ? { newSession: true as const } : {}) - } - } + private refusedStart(sessionId: string, refused: RefusedStart): StartFailure { + return refusedStructuredAgentSessionStart(this.deps.record(sessionId), refused) } private async fail(sessionId: string, failure: StartFailure): Promise<'stop'> { @@ -336,25 +353,7 @@ export class StructuredAgentSessionDeliveryLoop { /** Inside the serialized step that found nothing to do, so an accept after it wakes anew. */ private stop(sessionId: string): 'stop' { this.running.delete(sessionId) + this.deps.deliveryOrder.parkSends(sessionId) return 'stop' } } - -/** A start that died while this message waited on it — a view's, say — is the message's failed - * start: settled with it, under its key, rather than started again into the same failure. */ -function startThatFailedWhileQueued( - session: StructuredAgentSessionHostSession, - oldest: NonNullable> -): StartFailure | null { - const ended = failedProviderChildStart(session) - if ( - !ended || - oldest.acceptedSequence === undefined || - ended.endedAt.epoch !== session.journal.cursor().epoch || - ended.endedAt.sequence < oldest.acceptedSequence - ) { - return null - } - const cause = structuredAgentSessionEndedChildFailure(ended) - return cause ? { startKey: ended.generation, cause } : null -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-order.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-order.ts new file mode 100644 index 000000000000..8347f64c5127 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-order.ts @@ -0,0 +1,84 @@ +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' + +export type StructuredAgentSessionDeliveryPlace = { + before: Promise + release: () => void +} + +type Lane = { + tail: Promise + places: Set + sends: Map +} + +/** One delivery order; journal commits retire sends, bounded callers retire operations. */ +export class StructuredAgentSessionDeliveryOrder { + private readonly lanes = new Map() + + reconcile(sessionId: string, submissions: readonly AgentJournalSubmission[]): void { + const queued = submissions.filter(isQueuedAgentJournalSubmission) + const lane = this.lanes.get(sessionId) + const ids = new Set(queued.map((submission) => submission.clientMessageId)) + for (const [id, place] of lane?.sends ?? []) { + if (!ids.has(id)) { + lane?.sends.delete(id) + place.release() + } + } + for (const submission of queued.sort( + (a, b) => (a.acceptedSequence ?? 0) - (b.acceptedSequence ?? 0) + )) { + const current = this.lanes.get(sessionId) + if (!current?.sends.has(submission.clientMessageId)) { + const place = this.reserve(sessionId) + this.lanes.get(sessionId)?.sends.set(submission.clientMessageId, place) + } + } + } + + reserve(sessionId: string): StructuredAgentSessionDeliveryPlace { + const lane = this.lanes.get(sessionId) ?? { + tail: Promise.resolve(), + places: new Set(), + sends: new Map() + } + this.lanes.set(sessionId, lane) + const done = Promise.withResolvers() + const place: StructuredAgentSessionDeliveryPlace = { + before: lane.tail, + release: () => { + done.resolve() + lane.places.delete(place) + if (lane.places.size === 0 && this.lanes.get(sessionId) === lane) { + this.lanes.delete(sessionId) + } + } + } + lane.places.add(place) + lane.tail = Promise.all([place.before, done.promise]).then(() => undefined) + return place + } + + beforeSend(sessionId: string, messageId: string): Promise { + return this.lanes.get(sessionId)?.sends.get(messageId)?.before ?? Promise.resolve() + } + + /** Queued messages keep their journal rows, but stop ordering operations when delivery parks. */ + parkSends(sessionId: string): void { + const lane = this.lanes.get(sessionId) + for (const place of lane?.sends.values() ?? []) { + place.release() + } + lane?.sends.clear() + } + + dispose(): void { + for (const lane of this.lanes.values()) { + for (const place of lane.places) { + place.release() + } + } + this.lanes.clear() + } +} 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 2814638e0413..bbcadedabfef 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 @@ -143,7 +143,6 @@ async function attach(input: { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: input.params, now: () => NOW, onAttached: async (attached) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts index eb64f1b38709..227c99ad15c5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts @@ -23,7 +23,7 @@ const ENDED_CHILD_FAILURE = { 'user-close': () => null, 'context-clear': () => null, // The host stopping the child is Orca's cause, never the provider's: a start that never finished. - 'host-stop': () => ({ failure: agentSessionFailureFact('hostStopped') }), + 'host-stop': (ended) => ({ failure: ended.failure ?? agentSessionFailureFact('hostStopped') }), exit: providerEndFailure, // The attach records its own fault as the end's failure. 'attach-failed': providerEndFailure, 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 51894a9ff8d7..141afd9406eb 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 @@ -6,12 +6,12 @@ import { stopAgentSessionProviderRoot } from './structured-agent-session-provide import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession, - StructuredAgentSessionProviderChild + StructuredAgentSessionProviderChild, + StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' import { settleStructuredAgentSessionOptionsReported, - settleStructuredAgentSessionOptionsSkipped, settleStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' import { @@ -21,6 +21,7 @@ import { type StructuredAgentSessionChildExitContext } from './structured-agent-session-child-exit' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import { openStructuredAgentSessionProviderChild } from './structured-agent-session-provider-open' export class StructuredAgentSessionEventRecovery { private readonly sinkFailures = new Set() @@ -37,9 +38,28 @@ export class StructuredAgentSessionEventRecovery { now: () => number runtimeState: StructuredAgentSessionHostRuntimeState wakeDelivery: (sessionId: string) => Promise + stopStartingChild: ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity + ) => void + failStartingChild: ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity, + error: unknown + ) => Promise + trackOpen: (open: Promise) => Promise } ) {} + openProviderChild = ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity + ): void => { + void this.context.trackOpen( + openStructuredAgentSessionProviderChild(this.context, sessionId, child) + ) + } + private get exitContext(): StructuredAgentSessionChildExitContext { const { deps, runtimeState } = this.context return { @@ -60,7 +80,9 @@ export class StructuredAgentSessionEventRecovery { route: { runtimeState, acknowledgeRelease: (sessionId) => deps.adapter.acknowledgeSessionRelease?.(sessionId) - } + }, + abortOpen: (sessionId) => + runtimeState.acquireAborts.abort(sessionId, 'provider exited while opening') } } @@ -116,9 +138,6 @@ export class StructuredAgentSessionEventRecovery { if (event.type === 'options-reported') { return settleStructuredAgentSessionOptionsReported(this.context, event) } - if (event.type === 'options-skipped') { - return settleStructuredAgentSessionOptionsSkipped(this.context, event) - } await settleStructuredAgentSessionChildExit(this.exitContext, event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts new file mode 100644 index 000000000000..ebcd15553a78 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts @@ -0,0 +1,64 @@ +// A fake adapter's children prove their start once published, except those a test holds `starting`. + +import type { + StructuredAgentSessionAdapter, + StructuredAgentSessionLifecycleEvent +} from './structured-agent-session-adapter' + +type Acquire = StructuredAgentSessionAdapter['acquire'] + +export type StructuredAgentSessionHeldStarts = { + /** Wraps an acquire so the child it answers stays `starting`. */ + held(acquire: Acquire): Acquire + /** Wraps the adapter's acquire: every child not held proves its start once published. */ + wrap(adapter: A): A + /** Answers the pending protocol open. */ + prove(child: { sessionId: string; fence: number; acquisitionGeneration: string }): Promise + clear(): void +} + +export function structuredAgentSessionHeldStarts( + _host: () => { handleAdapterEvent(event: StructuredAgentSessionLifecycleEvent): Promise } +): StructuredAgentSessionHeldStarts { + const heldGenerations = new Map>() + let generations = 0 + const prove: StructuredAgentSessionHeldStarts['prove'] = async (child) => { + heldGenerations.get(child.acquisitionGeneration)?.resolve() + await Promise.resolve() + } + return { + held: (acquire) => async (input) => { + const acquired = await acquire(input) + const acquisitionGeneration = acquired.acquisitionGeneration ?? `held-start-${++generations}` + heldGenerations.set(acquisitionGeneration, Promise.withResolvers()) + return { ...acquired, acquisitionGeneration, providerChildPhase: 'starting' as const } + }, + wrap: (adapter) => { + const acquire: Acquire = async (input) => { + const acquired = await adapter.acquire(input) + const acquisitionGeneration = + acquired.acquisitionGeneration ?? `instant-start-${++generations}` + return { ...acquired, acquisitionGeneration, providerChildPhase: 'starting' as const } + } + const open: NonNullable = async (input) => { + if (input.acquisitionGeneration) { + await heldGenerations.get(input.acquisitionGeneration)?.promise + heldGenerations.delete(input.acquisitionGeneration) + } + return adapter.open + ? adapter.open(input) + : { + reportedOptions: { model: '' } + } + } + return { ...adapter, acquire, open } + }, + prove, + clear: () => { + for (const held of heldGenerations.values()) { + held.resolve() + } + heldGenerations.clear() + } + } +} 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 9791d4afda22..dd932c95676b 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 @@ -12,12 +12,15 @@ import { type StructuredAgentSessionConversationOpenOptions } from './structured-agent-session-conversation-open' import type { StructuredAgentSessionClientDelivery } from './structured-agent-session-client-delivery' +import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' import { StructuredAgentSessionDeliveryLoop } from './structured-agent-session-delivery-loop' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' +import type { StructuredAgentSessionStartReadiness } from './structured-agent-session-startup-attempt' import type { StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' @@ -58,6 +61,11 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessionId: string, startedFor: string ) => Promise + deliveryOrder: StructuredAgentSessionDeliveryOrder + readiness: ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity + ) => StructuredAgentSessionStartReadiness | null /** Stops a child the chat outgrew, so the start after it reads a new login or permission mode. */ stopOutgrownAgent: (sessionId: string) => Promise clientDelivery: Pick< @@ -96,6 +104,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { }) return input.ensureProviderChild(sessionId, startedFor) }, + readiness: input.readiness, + deliveryOrder: input.deliveryOrder, conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), holdClosed: async (sessionId, which) => { @@ -132,6 +142,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { } const wakesQueued = new Set() const afterCommit = (sessionId: string, journal: AgentSessionJournal): void => { + input.deliveryOrder.reconcile(sessionId, journal.submissions()) if ( wakesQueued.has(sessionId) || structuredAgentSessionCommandRunning(journal) || diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 8e072e714b6d..8e2633983d76 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 @@ -156,6 +156,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( cause, ...('quit' in ending && ending.quit ? { quit: true as const } : {}), reason: ('reason' in ending ? ending.reason : undefined) ?? null, + ...('failure' in ending && ending.failure ? { failure: ending.failure } : {}), recorded, requestedAt: session.journal.cursor() } 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 78002aa6f6c1..abf82c9e604b 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 @@ -7,6 +7,8 @@ // admitted without the writer lease; the delivery loop starts the provider child a send needs, and // an operation only the provider can perform starts it before admission. +import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../shared/agent-chat-permission-mode' +import { performSetOption } from './structured-agent-session-turns-options' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult, @@ -19,18 +21,18 @@ import type { AgentSessionThreadGoalResult } from '../../../shared/agent-session-wire' import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' -import { threadGoalPlan } from './structured-agent-session-thread-goal' +import { refuseThreadGoalUnsupported, threadGoalPlan } from './structured-agent-session-thread-goal' import { mutateStructuredAgentSession, + runStructuredAgentSessionMutation, type StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import { openForProviderWrite, - openWithAgent, + openForWrite, sendPreparation, structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' -import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../shared/agent-chat-permission-mode' import { cancelPlan, promptPlan, @@ -42,7 +44,11 @@ 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' +import { + runWithReadyStructuredAgentSessionOperation, + openWithReadyStructuredAgentSessionChild, + runAfterRewindStart +} from './structured-agent-session-ready-operation' export type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { @@ -74,23 +80,25 @@ export function sendStructuredAgentSessionTurn( arrival?: Parameters[2] ): Promise> { const plan = sendPlan(params) - return mutateStructuredAgentSession( - context, - caller, - params.envelope, - { - ...plan, - run: (ctx) => - runQueueableStructuredAgentSessionSend( - context, - ctx, - params, - async () => - structuredAgentSessionSendBlock(context.deps.store.getRecord(ctx.sessionId)) ?? - (await plan.run(ctx)) - ) - }, - sendPreparation(context, params.envelope, arrival) + return runAfterRewindStart(context, caller.callerKey, params.envelope, plan, (selection) => + runStructuredAgentSessionMutation( + context, + caller, + params.envelope, + { + ...plan, + run: (ctx) => + runQueueableStructuredAgentSessionSend( + context, + ctx, + params, + async () => + structuredAgentSessionSendBlock(context.deps.store.getRecord(ctx.sessionId)) ?? + (await plan.run(ctx)) + ) + }, + sendPreparation(context, params.envelope, arrival, selection) + ) ) } @@ -191,55 +199,77 @@ export async function setStructuredAgentSessionOption( caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope; key: string; value: string } ): Promise> { - // The permission mode is only ever the record's: the next start launches with it. const recordOnly = params.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID - if (!recordOnly) { - // Outside the queue: a pick made while the provider starts then queues behind what its start persists. - await context.deps.adapter.awaitOptionWritable?.(params.envelope.sessionId) - } const plan = setOptionPlan(params) - const atRest = () => recordOnly || !context.sessions.get(params.envelope.sessionId)?.child - return mutateStructuredAgentSession( + const atRest = () => { + const child = context.sessions.get(params.envelope.sessionId)?.child + return recordOnly || !child || child.phase === 'starting' || child.close !== undefined + } + return runWithReadyStructuredAgentSessionOperation( context, - caller, + caller.callerKey, params.envelope, + plan, + (selection) => + runStructuredAgentSessionMutation( + context, + caller, + params.envelope, + { + ...plan, + get conversationWrite() { + return atRest() ? (true as const) : undefined + }, + run: async (ctx) => { + if (atRest()) { + return recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) + } + const wait = context.acquireAborts.begin(params.envelope.sessionId) + try { + return await performSetOption(ctx, params, wait.signal) + } finally { + wait.end() + } + } + }, + (ledger) => + atRest() + ? openForWrite(context, params.envelope)() + : openWithReadyStructuredAgentSessionChild(context, params.envelope, selection)(ledger) + ), { - ...plan, - // Read as the call is admitted: with no child running, the pick is a conversation write — - // intent the next start replays. A running child's pick is still its owner's to make. - get conversationWrite() { - return atRest() ? (true as const) : undefined + needsAgent: () => { + const child = context.sessions.get(params.envelope.sessionId)?.child + return !recordOnly && Boolean(child && !child.close) }, - 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.acquireAborts.begin(params.envelope.sessionId) - try { - return await performSetOption(ctx, params, wait.signal) - } finally { - wait.end() - // Whatever the write's outcome, the child may hold what it asked for now. - context.optionRevisions.advance(params.envelope.sessionId) - } - } - }, - openForProviderWrite(context, params.envelope) + runAfterAgentEnds: true + } ) } -export function changeStructuredAgentSessionThreadGoal( +export async function changeStructuredAgentSessionThreadGoal( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope; change: AgentSessionThreadGoalChange } ): Promise> { - return mutateStructuredAgentSession( + const plan = threadGoalPlan(params) + return runWithReadyStructuredAgentSessionOperation( context, - caller, + caller.callerKey, params.envelope, - threadGoalPlan(params), - openWithAgent(context, params.envelope) + plan, + (selection) => + runStructuredAgentSessionMutation( + context, + caller, + params.envelope, + plan, + openWithReadyStructuredAgentSessionChild(context, params.envelope, selection) + ), + { + beforeAgent: (record) => + refuseThreadGoalUnsupported(context.deps.adapter, context.deps.agents, record?.provider) + } ) } 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 d604b805eced..04a1ade9f101 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 @@ -1,3 +1,4 @@ +import { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { @@ -12,7 +13,6 @@ import { heldProviderChildReader, type ProviderChildSessions } from './structured-agent-session-provider-child' -import { StructuredAgentSessionOptionRevisions } from './structured-agent-session-option-revisions' import { resolveStructuredSessionRecovery } from './structured-agent-session-recovery-resolution' import { recordAgentSessionStartup } from '../../observability/agent-session-instrumentation' import { @@ -23,9 +23,9 @@ import { export class StructuredAgentSessionHostRuntimeState { private readonly eventSinks = new Map() + readonly deliveryOrder = new StructuredAgentSessionDeliveryOrder() readonly acquireAborts = new StructuredAgentSessionAcquireAborts() readonly startupAttempts: StructuredAgentSessionStartupAttempts - readonly optionRevisions = new StructuredAgentSessionOptionRevisions() private readonly leaseRenewer: StructuredAgentSessionLeaseRenewer private readonly onEventSinkFailure?: (sessionId: string, error: unknown) => void @@ -42,14 +42,11 @@ export class StructuredAgentSessionHostRuntimeState { ...(deps.startupLimits ? { limits: deps.startupLimits } : {}), settled: recordAgentSessionStartup, expire: (expired) => { - if (expired.child === null) { - this.acquireAborts.abort( - expired.sessionId, - new StructuredAgentSessionStartupExpiredError() - ) - return + // Still acquiring, or applying the picks made meanwhile: either wait ends now. + this.acquireAborts.abort(expired.sessionId, new StructuredAgentSessionStartupExpiredError()) + if (expired.child !== null) { + onStartupExpired?.(expired) } - onStartupExpired?.(expired) } }) this.leaseRenewer = new StructuredAgentSessionLeaseRenewer({ 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 d9b8e068c37e..16c63515f370 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 @@ -10,6 +10,7 @@ import { structuredAgentSessionConversationFence } from './structured-agent-sess import type { JournalStopSettle } from '../agent-session-journal/queued-message-pause' import { sentSinceStop } from './structured-agent-session-queued-stop' import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' +import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' /** 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 @@ -19,6 +20,7 @@ export type StructuredAgentSessionStopEnding = | { cause: Exclude reason?: string + failure?: SubmissionRejectionFact /** The app is quitting, and why: a turn this stop cuts gets a row naming it. */ quit?: AgentSessionResumeTrigger /** The idle sweep judged the agent resting (`owesWork`): a send it retires unanswered is 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 ab748f866b5b..d4fb189ede09 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 @@ -32,6 +32,7 @@ import { 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' +import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' const journals = createTrackedJournalOpener() @@ -83,17 +84,20 @@ function accepted(): AgentSessionDispatchOutcome { } function adapter(): StructuredAgentSessionAdapter { - return { - acquire, - releaseAcquisition, - dispatch: async (input) => { - await input.beforeDispatch?.() - return dispatch(input) + return startsWhenPublished( + { + acquire, + releaseAcquisition, + dispatch: async (input) => { + await input.beforeDispatch?.() + return dispatch(input) + }, + cancelTurn, + answerPrompt, + setOption }, - cancelTurn, - answerPrompt, - setOption - } + () => host + ) } async function attach(): Promise { 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 c87dec4421db..684d9b776f67 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 @@ -57,6 +57,7 @@ export type StructuredAgentSessionChildClose = { /** Asked for by quit, whose leftovers the next open settles as a crash's. */ readonly quit?: true readonly reason: string | null + readonly failure?: SubmissionRejectionFact /** The Stop event that stop wrote, folded before the work it ends is settled, with the settle a * person's close that named no turn opens; a repeated ask reopens it. */ recorded: Promise @@ -68,8 +69,8 @@ export type StructuredAgentSessionChildClose = { /** The provider process behind a conversation. Written only in * `structured-agent-session-provider-child`. */ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProviderChildIdentity & { - /** A publish-first acquire is `starting` until the adapter's `started` event; only then are its - * reported options fact. */ + /** A starting acquisition stays `starting` until the host settles its `started` event; only then is + * it handed input and are its reported options fact. */ phase: StructuredAgentSessionProviderChildPhase /** 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. */ 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 62568e18a181..109fdbdb71d4 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 @@ -24,7 +24,7 @@ import { type StructuredAgentSessionConversationLifetime } from './structured-agent-session-conversation-lifetime' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' -import * as sessionTabs from './structured-agent-session-host-tabs' +import { createStructuredAgentSessionTabSurface as createTabs } from './structured-agent-session-host-tabs' import { structuredAgentSessionMutationDelegates, type StructuredAgentSessionMutationContext @@ -124,6 +124,8 @@ export class StructuredAgentSessionHost { trackStart: (start) => this.tasks.trackAttach(start), ensureProviderChild: (sessionId, startedFor) => agentStart.ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), + deliveryOrder: this.runtimeState.deliveryOrder, + readiness: (id, child) => this.runtimeState.startupAttempts.readiness(id, child), // Put to rest like an idle agent: the stop writes nothing, and the next start is new. stopOutgrownAgent: (id) => this.lifetime.stopAgent(id, { cause: 'evict', resting: true }), clientDelivery: this.clientDelivery @@ -153,7 +155,10 @@ export class StructuredAgentSessionHost { serialize: (sessionId, task) => this.tasks.trackAttach(this.serialize(sessionId, task)), now: () => this.now(), runtimeState: this.runtimeState, - wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId) + wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), + stopStartingChild: (...args) => this.lifetime.stopStartingChild(...args), + failStartingChild: (...args) => this.lifetime.failStartingChild(...args), + trackOpen: (open) => this.tasks.trackAttach(open) }) this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { ...structuredAgentSessionRestartResumeSurfaces(this, this.now), @@ -176,8 +181,7 @@ export class StructuredAgentSessionHost { private now = (): number => this.deps.now?.() ?? Date.now() hasSession = (sessionId: string): boolean => this.sessions.has(sessionId) - hasLoadedProviders = (): boolean => - [...this.sessions.values()].some(({ child }) => child !== null) + hasLoadedProviders = () => [...this.sessions.values()].some(({ child }) => child !== null) sessionAgent = (sessionId: string) => this.deps.store.getRecord(sessionId)?.provider ?? null handleAdapterEvent = (event: Parameters[0]) => @@ -204,6 +208,7 @@ export class StructuredAgentSessionHost { tasks: this.tasks, reconcileLeases: (sessionId) => this.reconcileLeases(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), + openProviderChild: this.eventRecovery.openProviderChild, openConversation: this.conversationDelivery.open } } @@ -220,11 +225,7 @@ export class StructuredAgentSessionHost { knownAgentIds = (): readonly string[] => providerSupport.knownAgentIds(this.deps) - private readonly tabs = sessionTabs.createStructuredAgentSessionTabSurface( - this, - this.sessions, - (sessionId) => this.clientDelivery.forgetStatus(sessionId) - ) + private readonly tabs = createTabs(this, this.sessions, this.clientDelivery.forgetStatus) listSessionTabs = this.tabs.listSessionTabs getPersistedVisibleSessionTabIndex = this.tabs.getPersistedVisibleSessionTabIndex getSessionTabId = this.tabs.getSessionTabId @@ -257,8 +258,7 @@ export class StructuredAgentSessionHost { // 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, and the * queue hands no card over. */ - stopDelivery = (): void => - [this.conversationDelivery, this.queued.drain].forEach((d) => d.dispose()) + stopDelivery = () => [this.conversationDelivery, this.queued.drain].forEach((d) => d.dispose()) async flushAllStreamedEvents(options?: { trigger?: 'quit' | 'update' }): Promise { this.stopDelivery() @@ -285,13 +285,14 @@ export class StructuredAgentSessionHost { openConversation: this.conversationDelivery.open, ensureAgent: (sessionId) => agentStart.ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), + startupAttempts: this.runtimeState.startupAttempts, + deliveryOrder: this.runtimeState.deliveryOrder, 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), acquireAborts: this.runtimeState.acquireAborts, - optionRevisions: this.runtimeState.optionRevisions, now: () => this.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts new file mode 100644 index 000000000000..a1a99ce71836 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts @@ -0,0 +1,27 @@ +// Fake children answer the host-awaited protocol open immediately. + +import type { + StructuredAgentSessionAdapter, + StructuredAgentSessionLifecycleEvent +} from './structured-agent-session-adapter' + +export function startsWhenPublished( + adapter: A, + _host: () => { handleAdapterEvent(event: StructuredAgentSessionLifecycleEvent): Promise } +): A { + let generations = 0 + const acquire: StructuredAgentSessionAdapter['acquire'] = async (input) => { + const acquired = await adapter.acquire(input) + const acquisitionGeneration = acquired.acquisitionGeneration ?? `instant-start-${++generations}` + return { ...acquired, acquisitionGeneration, providerChildPhase: 'starting' as const } + } + return { + ...adapter, + acquire, + open: + adapter.open ?? + (async () => ({ + reportedOptions: { model: '' } + })) + } +} 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 268b71d152aa..209260f1406c 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 @@ -32,6 +32,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-ho 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' +import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' const CALLER = { callerKey: 'client-1' } @@ -101,29 +102,33 @@ beforeEach(async () => { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire: vi.fn(async ({ fence }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created' as const, - mintedAtFence: fence, - observedAt: NOW - } - })), - releaseAcquisition: vi.fn(async () => true), - dispatch, - closeSession, - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, + adapter: startsWhenPublished( + { + acquire: vi.fn(async ({ fence }) => ({ + providerChildPhase: 'starting' as const, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: 'created' as const, + mintedAtFence: fence, + observedAt: NOW + } + })), + releaseAcquisition: vi.fn(async () => true), + dispatch, + closeSession, + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + () => host + ), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', 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 918afba6c5b3..e7a814ec0481 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 @@ -27,6 +27,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-ho 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' +import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' const CALLER = { callerKey: 'client-1' } @@ -48,15 +49,18 @@ function openHost(): void { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: false })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, + adapter: startsWhenPublished( + { + acquire, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + dispatch, + cancelTurn: vi.fn(async () => ({ cancelled: false })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + () => host + ), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, @@ -125,6 +129,7 @@ beforeEach(async () => { stopOwnerProcess = vi.fn() dispatch = vi.fn(async () => ({ state: 'admitted' as const })) acquire = vi.fn(async ({ fence, spawnToken }) => ({ + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, link: { linkId: `link-${fence}`, 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 763acc16c781..9b65150cc704 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 @@ -40,6 +40,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-ho 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' +import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' const CALLER = { callerKey: 'client-1' } const PROVIDER_ROW = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1' } @@ -68,33 +69,37 @@ beforeEach(async () => { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire: async (input) => { - events = input.events - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: input.spawnToken - }, - acquisitionGeneration: 'generation-1', - link: { - linkId: `link-${input.fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created' as const, - mintedAtFence: input.fence, - observedAt: NOW + adapter: startsWhenPublished( + { + acquire: async (input) => { + events = input.events + return { + providerChildPhase: 'starting' as const, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: input.spawnToken + }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${input.fence}`, + handle: codexProviderHandle(THREAD), + origin: 'created' as const, + mintedAtFence: input.fence, + observedAt: NOW + } } - } + }, + dispatch, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) }, - dispatch, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, + () => host + ), statusSink: { publish: () => {}, forget: () => {}, readChildWork: () => records }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts index 99120dcc23b5..6422b4b8437e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts @@ -195,29 +195,48 @@ it('logs a wake error and still answers accepted', async () => { ) }) -it('logs a receipt read error and refuses unknown without accepting or waking', async () => { - const ctx = context() - const request = params() - const error = new Error('receipt read failed') - const warn = vi.spyOn(ctx.deps.logger, 'warn') - vi.spyOn(rig.store, 'readCommandReceipt').mockImplementationOnce(() => { - throw error - }) - expect(await sendStructuredAgentSessionTurn(ctx, CALLER, request)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_unknown', details: { reason: 'outcomeUnknown' } } - }) - expect(warn).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - scope: 'command-receipt-read', - operationId: request.envelope.clientOperationId, - error +it.each([false, true])( + 'logs a receipt read error and refuses unknown without accepting or waking (rewind: %s)', + async (rewinding) => { + const ctx = context() + const request = params() + if (rewinding) { + await rig.store.transitionHandoff(SESSION, (record) => ({ + ...record, + rewind: { + operationId: hostTestOperationId(), + callerKey: CALLER.callerKey, + itemId: 'prior-message', + providerItemId: 'prior-provider-item', + expectedEpoch: rig.host['sessions'].get(SESSION)!.journal.cursor().epoch, + phase: 'prepared', + retained: [] + } + })) + } + const ensureAgent = vi.spyOn(ctx, 'ensureAgent') + const error = new Error('receipt read failed') + const warn = vi.spyOn(ctx.deps.logger, 'warn') + vi.spyOn(rig.store, 'readCommandReceipt').mockImplementationOnce(() => { + throw error }) - ) - expect(rig.host['sessions'].get(SESSION)?.journal.submissions()).toHaveLength(0) - expect(ctx.wakeDelivery).not.toHaveBeenCalled() -}) + expect(await sendStructuredAgentSessionTurn(ctx, CALLER, request)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown', details: { reason: 'outcomeUnknown' } } + }) + expect(warn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + scope: 'command-receipt-read', + operationId: request.envelope.clientOperationId, + error + }) + ) + expect(rig.host['sessions'].get(SESSION)?.journal.submissions()).toHaveLength(0) + expect(ctx.wakeDelivery).not.toHaveBeenCalled() + expect(ensureAgent).not.toHaveBeenCalled() + } +) it('wakes once for /compact and never for its replay', async () => { const ctx = context() 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 f384fa866eae..a3934b921b3d 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 @@ -15,7 +15,8 @@ import { 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 { StructuredAgentSessionOptionRevisions } from './structured-agent-session-option-revisions' +import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' +import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, @@ -38,6 +39,8 @@ export type StructuredAgentSessionMutationContext = { openConversation: (sessionId: string) => Promise /** Gives the session a provider child; inside the caller's serialize. */ ensureAgent: (sessionId: string) => Promise + startupAttempts: Pick + deliveryOrder: StructuredAgentSessionDeliveryOrder /** 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 @@ -54,7 +57,6 @@ export type StructuredAgentSessionMutationContext = { * outside that serialize aborts. */ acquireAborts: Pick /** Moved by every pick a running child takes, so a report it read before is never persisted. */ - optionRevisions: Pick now: () => number } @@ -67,19 +69,30 @@ export function mutateStructuredAgentSession( prepareSession?: AgentSessionMutationRequest['prepareSession'] ): Promise> { return context.serialize(envelope.sessionId, () => - admitAndRunAgentSessionMutation({ - store: context.deps.store, - adapter: context.deps.adapter, - agents: context.deps.agents, - logger: context.deps.logger, - callerKey: caller.callerKey, - envelope, - plan, - journal: () => context.sessions.get(envelope.sessionId)?.journal, - prepareSession, - publish: (journal) => context.publish(envelope.sessionId, journal), - wakeDelivery: (sessionId) => context.wakeDelivery(sessionId), - now: () => context.now() - }) + runStructuredAgentSessionMutation(context, caller, envelope, plan, prepareSession) ) } + +/** Admission for a caller already inside the session's queue. */ +export function runStructuredAgentSessionMutation( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + envelope: AgentSessionMutationEnvelope, + plan: MutationPlan, + prepareSession?: AgentSessionMutationRequest['prepareSession'] +): Promise> { + return admitAndRunAgentSessionMutation({ + store: context.deps.store, + adapter: context.deps.adapter, + agents: context.deps.agents, + logger: context.deps.logger, + callerKey: caller.callerKey, + envelope, + plan, + journal: () => context.sessions.get(envelope.sessionId)?.journal, + prepareSession, + publish: (journal) => context.publish(envelope.sessionId, journal), + wakeDelivery: (sessionId) => context.wakeDelivery(sessionId), + now: () => context.now() + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 28fceb65b39d..b7a7121a4e85 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -190,7 +190,7 @@ export function sendPlan(params: { .submissions() .find((entry) => entry.clientMessageId === clientMessageId) if (submission) { - return { clientMessageId, submission } + return { clientMessageId, submission: { ...submission } } } // A pending row wrote nothing, so the send runs for the first time. Succeeded: accepted, // then a new epoch dropped its row. Unknown: only builds before this one wrote that. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-revisions.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-revisions.ts deleted file mode 100644 index 0c20a84c1f59..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-revisions.ts +++ /dev/null @@ -1,46 +0,0 @@ -// The order of what a conversation's record learns about its child's options. -// -// A pick and a child's report both write the record's options, but a report describes the child as -// of its read, which can predate a pick still in flight, a saved value the child showed it cannot -// run, or a newer report. A pick or such a value moves the revision; a report carries the revision -// it was read at and is admitted only if nothing moved it since. Reports are ordered among -// themselves by arrival, so a read's stamp never depends on when an earlier report was taken, and -// the newest admitted report is the only one persisted. - -export class StructuredAgentSessionOptionRevisions { - private readonly revisions = new Map() - private readonly newestReports = new Map() - private reports = 0 - - current(sessionId: string): number { - return this.revisions.get(sessionId) ?? 0 - } - - /** What any report read before now says is out of date. */ - advance(sessionId: string): number { - const next = this.current(sessionId) + 1 - this.revisions.set(sessionId, next) - return next - } - - /** A report read at `readAt` becomes the newest word, or null when a pick came since its read. */ - admitReport(sessionId: string, readAt: number): number | null { - if (readAt !== this.current(sessionId)) { - return null - } - const admitted = ++this.reports - this.newestReports.set(sessionId, admitted) - return admitted - } - - /** Whether the report admitted as `admitted`, read at `readAt`, is still the newest word. */ - isNewest(sessionId: string, readAt: number, admitted: number): boolean { - return readAt === this.current(sessionId) && this.newestReports.get(sessionId) === admitted - } - - /** The conversation closed with no child: nothing it reported is still to be persisted. */ - forget(sessionId: string): void { - this.revisions.delete(sessionId) - this.newestReports.delete(sessionId) - } -} 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 67085dfbcf22..1876416e7d5a 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 @@ -10,7 +10,6 @@ import type { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { HOST_TEST_NOW as NOW, @@ -27,7 +26,6 @@ import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router- const CALLER = { callerKey: 'client-1' } const DEFAULT_MODEL = 'gpt-default' -const PICKED_MODEL = 'gpt-picked' let root: string let store: AgentSessionRecordStore @@ -56,10 +54,21 @@ function envelope(method: string, fields: Record): AgentSession } function adapter(): StructuredAgentSessionAdapter { - acquire = vi.fn(async ({ fence, spawnToken, options }) => { + acquire = vi.fn(async ({ fence, spawnToken, options, identity }) => { activeModel = options?.model ?? DEFAULT_MODEL activeEffort = options?.effort ?? null + const acquisitionGeneration = `generation-${acquire.mock.calls.length}` + // The handshake answers at once, reporting the options the child runs with. + void host.handleAdapterEvent({ + type: 'started', + sessionId: identity.sessionId, + fence, + acquisitionGeneration, + reportedOptions: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) } + }) return { + providerChildPhase: 'starting' as const, + acquisitionGeneration, process: { hostId: 'local', pid: 4200 + acquire.mock.calls.length, @@ -149,7 +158,7 @@ afterEach(async () => { }) describe('structured session options and close', () => { - it('publishes an acknowledged model without waiting for journal traffic', async () => { + it('keeps provider readback out of the saved next-message choice', async () => { const body = { kind: 'message' as const, role: 'user' as const, @@ -169,47 +178,11 @@ describe('structured session options and close', () => { expect(events).toEqual([ { type: 'snapshot', - sessions: [expect.objectContaining({ status: 'idle', model: DEFAULT_MODEL })] - } - ]) - const fields = { key: 'model', value: PICKED_MODEL } - - await host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', fields), - ...fields - }) - - expect(events.slice(1)).toEqual([ - { - type: 'status', - session: expect.objectContaining({ sessionId: SESSION, model: PICKED_MODEL }) + sessions: [expect.objectContaining({ status: 'idle' })] } ]) - }) - - it('settles a pre-mutation rejection so a fresh retry can succeed', async () => { - optionFailure = new AgentSessionOptionRejectedError('model list unavailable') - const fields = { key: 'model', value: PICKED_MODEL } - const rejected = { - envelope: envelope('agentSession.setOption', fields), - ...fields - } - - expect(await host.setOption(CALLER, rejected)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid', message: 'model list unavailable' } - }) - expect(await host.setOption(CALLER, rejected)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid' } - }) - expect( - await host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', fields), - ...fields - }) - ).toMatchObject({ ok: true, value: { options: { model: PICKED_MODEL } } }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL }) + expect(events[0]).not.toHaveProperty('sessions.0.model') + expect(store.getRecord(SESSION)?.options).toBeUndefined() }) // Closing a chat used to leave its provider child resident for the whole app session: the host's @@ -239,3 +212,29 @@ describe('structured session options and close', () => { expect(closeNativeSession).not.toHaveBeenCalled() }) }) + +it('applies running Codex model and effort picks to the next message', async () => { + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) + for (const [key, value] of [ + ['model', 'gpt-picked'], + ['effort', 'high'] + ]) { + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', { key, value }), + key, + value + }) + ).toMatchObject({ ok: true }) + } + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'with the new choices' }] + } + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(dispatchedModels).toEqual(['gpt-picked'])) + expect(activeEffort).toBe('high') +}) 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 6ffbb4b5ba9b..647c08ca9758 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 @@ -25,6 +25,50 @@ function restingRecord(): AgentSessionRecord { } describe('options at rest', () => { + it('keeps the account model for an older client when the starting child has no folder default', async () => { + const record = restingRecord() + const read = vi.fn(async (params: { inSessionWorkspace?: true }) => ({ + origin: 'probe' as const, + models: [ + { + id: params.inSessionWorkspace ? 'gpt-folder' : 'gpt-account', + label: 'Model', + isDefault: !params.inSessionWorkspace, + efforts: [] + } + ], + fetchedAt: 1 + })) + const starting = { + child: { phase: 'starting' }, + params: { provider: 'codex' }, + journal: { context: { floor: () => null } } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields. + const context = { + deps: { + adapter: {}, + agents: NO_STRUCTURED_AGENTS, + store: { getRecord: () => record }, + modelCatalog: { read } + }, + serialize: (_sessionId: string, task: () => Promise) => task(), + openConversation: async () => starting, + conversation: async () => starting + } as unknown as StructuredAgentSessionMutationContext + + const current = await readStructuredAgentSessionOptions(context, SESSION, { + readsWithoutModel: true + }) + expect(current.current).toEqual({ permissionMode: 'ask' }) + expect(read).toHaveBeenCalledTimes(1) + const older = await readStructuredAgentSessionOptions(context, SESSION, { + readsWithoutModel: false + }) + expect(older.current).toEqual({ model: 'gpt-account', permissionMode: 'ask' }) + expect(read).toHaveBeenLastCalledWith({ agent: 'codex', sessionId: SESSION, savedOnly: true }) + }) + it.each(['codex', 'omp', 'grok', 'opencode'] as const)( 'omits an unknown model for %s while the catalog is unavailable', async (provider) => { @@ -58,7 +102,7 @@ describe('options at rest', () => { const result = await readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) - expect(probe).toHaveBeenCalledTimes(provider === 'codex' ? 1 : 0) + expect(probe).not.toHaveBeenCalled() expect(result.models).toEqual([]) expect(result.current).not.toHaveProperty('model') record.options = { model: 'later-model', effort: 'off' } @@ -76,13 +120,14 @@ describe('options at rest', () => { }) describe('live Codex option reads', () => { - it('leaves the session lane free while a cold listing waits, then applies it under the child fence', async () => { + it('keeps newer saved picks after a cold live listing waits without holding the session lane', async () => { const listing = Promise.withResolvers() const queue = new StructuredAgentSessionTaskQueue() const child = { fence: 7 } + const record = restingRecord() const apply = vi.fn(() => ({ models: [{ id: 'gpt-live', label: 'GPT Live', efforts: [] }], - current: { model: 'gpt-live' } + current: { model: 'gpt-live', effort: 'high', confirmed: ['model', 'effort'] as const } })) const prepareReadOptions = vi.fn(async () => { await listing.promise @@ -98,7 +143,7 @@ describe('live Codex option reads', () => { deps: { adapter: { prepareReadOptions }, agents: NO_STRUCTURED_AGENTS, - store: { getRecord: () => restingRecord() } + store: { getRecord: () => record } }, serialize: (sessionId: string, task: () => Promise) => queue.serialize(sessionId, task), @@ -114,8 +159,11 @@ describe('live Codex option reads', () => { 'delivery can proceed' ) expect(apply).not.toHaveBeenCalled() + record.options = { model: 'gpt-next', effort: 'low' } listing.resolve() - await expect(reading).resolves.toMatchObject({ current: { model: 'gpt-live' } }) + await expect(reading).resolves.toMatchObject({ + current: { model: 'gpt-next', effort: 'low', confirmed: [] } + }) expect(apply).toHaveBeenCalledOnce() }) 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 475c310350df..83943eb314b6 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 @@ -5,6 +5,7 @@ // exists. A pick made at rest is written to the record as intent, through the same transition a live // pick takes, so the next start applies it. +import { saveStructuredAgentSessionListing } from './structured-agent-session-start-listing' import { refuse, type AgentSessionModelCatalogResult, @@ -95,6 +96,7 @@ async function readStructuredAgentSessionOptionsAtRest( ?.read({ agent: record.provider, sessionId, + savedOnly: true, ...(inSessionWorkspace ? { inSessionWorkspace: true as const } : {}) }) .catch(() => null)) ?? { origin: 'unknown' } @@ -185,7 +187,8 @@ export async function readStructuredAgentSessionOptions( throw journalOpenReadRefusal(error, context.deps.logger, sessionId) }) const child = session?.child - if (!child) { + // A starting child is asked nothing: the answer is the saved intent and the catalog. + if (!child || child.phase === 'starting') { return { kind: 'rest' as const } } const prepared = adapter.prepareReadOptions?.({ sessionId, fence: child.fence }) @@ -216,7 +219,7 @@ export async function readStructuredAgentSessionOptions( const { catalogListing, ...answer } = live if (catalogListing) { // What a running child listed is its account's catalog too, so the next chat opens warm. - context.deps.modelCatalog?.recordLiveListing(sessionId, catalogListing) + saveStructuredAgentSessionListing(context, sessionId, catalogListing) } options = answer } else { @@ -234,9 +237,28 @@ export async function readStructuredAgentSessionOptions( const permissionMode = record ? await structuredAgentSessionLaunchPermission(context.deps, record) : null + const saved = record?.options + const selected = + live && (saved?.model !== undefined || saved?.effort !== undefined) + ? await readStructuredAgentSessionOptionsAtRest(context.deps, sessionId, reader) + : options + const current = { + ...options.current, + ...(saved?.model !== undefined ? { model: selected.current.model } : {}), + ...(saved?.effort !== undefined ? { effort: selected.current.effort } : {}) + } + const confirmed = current.confirmed?.filter((key) => + key === 'model' + ? current.model === options.current.model + : key !== 'effort' || current.effort === options.current.effort + ) return { ...options, - ...(permissionMode ? { current: { ...options.current, permissionMode } } : {}), + current: { + ...current, + ...(confirmed ? { confirmed } : {}), + ...(permissionMode ? { permissionMode } : {}) + }, rewind: phase === 'prepared' || phase === 'provider-succeeded' ? { supported: false, reason: 'outcome-unknown' } 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 6d188fb3b385..f4ffba515285 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 @@ -17,8 +17,7 @@ import { AgentHookServer, _internals } from '../../agent-hooks/server' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID, - claudeStartupSettled + PROVIDER_SESSION_ID } 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' @@ -175,29 +174,26 @@ function waitOutSeveralSweeps(): Promise { return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) } -function deliverySettled(): Promise { - return vi.waitFor(() => - expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) - ) -} - describe('a Claude chat whose CLI is still starting', () => { it('holds a message until startup lands, and keeps the agent inside the idle window meanwhile', async () => { await attachStarting() const sent = await send('sent while starting') // Held, never written, while initialize is unanswered. - await deliverySettled() expect(claude.connections[0].sent).toEqual([]) expect(await dispatchState(sent)).toBe('pending') clock += IDLE_MS - 1 await waitOutSeveralSweeps() + expect(claude.connections[0].sent).toEqual([]) + expect(await dispatchState(sent)).toBe('pending') expect(host.hasSession(SESSION)).toBe(true) expect(claude.connections[0].closeCount).toBe(0) landInit() - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) await untilSent(claude.connections[0]) expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) await vi.waitFor(async () => expect(await dispatchState(sent)).toBe('accepted')) @@ -212,16 +208,20 @@ describe('a Claude chat whose CLI is still starting', () => { connection.sent.push(message) } const held = await send('sent while starting') - await deliverySettled() expect(connection.sent).toEqual([]) + expect(await dispatchState(held)).toBe('pending') clock += IDLE_MS - 1 await waitOutSeveralSweeps() + expect(connection.sent).toEqual([]) + expect(await dispatchState(held)).toBe('pending') expect(host.hasSession(SESSION)).toBe(true) // Startup lands just before the window closes and the message is handed over; the agent stays // while its message waits. landInit() - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) await Promise.all(lifecycle) await untilSent(connection) expect(connection.sent).toEqual([expect.objectContaining({ type: 'user' })]) @@ -240,7 +240,9 @@ describe('a Claude chat whose CLI is still starting', () => { it('stops the agent and closes the conversation once its turn has finished and it idled', async () => { await attachStarting() landInit() - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) const answered = await send('answered') await vi.waitFor(async () => expect(await dispatchState(answered)).toBe('accepted')) claude.connections[0].handlers.onMessage?.({ @@ -278,7 +280,9 @@ describe('a chat whose settled lead still has background work running', () => { async function settleTurnLeavingTask(taskType: string): Promise { await attachStarting() landInit() - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) const fanOut = await send('fan out') await vi.waitFor(async () => expect(await dispatchState(fanOut)).toBe('accepted')) frame({ 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 c9ecb34dad48..2d82658a1bd1 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 @@ -100,7 +100,6 @@ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { probe: { outcome: 'reservation-unused' as const } }, callerKey: 'client-1', - optionRevision: () => 0, params: createParams(), now: () => NOW, onAttached: () => {} 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 bebf68bd2f34..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 @@ -97,7 +97,6 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} @@ -146,7 +145,6 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' as const } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} @@ -186,7 +184,6 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' as const } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} @@ -240,7 +237,6 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} @@ -310,7 +306,6 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' as const } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index c7a9997117e3..5d31de269803 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 @@ -44,6 +44,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-ho 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' +import { structuredAgentSessionHeldStarts } from './structured-agent-session-held-start.test-support' const CALLER = { callerKey: 'client-1' } const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { @@ -66,6 +67,7 @@ function generation(): string { } const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, acquisitionGeneration: generation(), link: { @@ -79,27 +81,26 @@ const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spa } }) -/** A Claude-shaped child: published at spawn, so it is `starting` until `started`. */ -const spawnStartingChild: StructuredAgentSessionAdapter['acquire'] = async (input) => ({ - ...(await spawnChild(input)), - providerChildPhase: 'starting' as const -}) +const starts = structuredAgentSessionHeldStarts(() => host) + +/** A child whose handshake is still running: `starting` until the test's `prove()`. */ +const spawnStartingChild = starts.held(spawnChild) function startHost(): void { host = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire, + adapter: starts.wrap({ dispatch, closeSession: vi.fn(async () => true), releaseAcquisition: vi.fn(async () => true), cancelTurn: vi.fn(async () => ({ cancelled: false })), answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined), - ...adapterExtras - }, + ...adapterExtras, + acquire + }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, @@ -116,6 +117,7 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-child-record-')) resetHostTestOperationIds() adapterExtras = {} + starts.clear() acquire = vi.fn(spawnChild) dispatch = vi.fn(async (input) => ({ state: 'accepted' as const, @@ -222,21 +224,18 @@ function prove(): Promise { type: 'started', ...child, acquisitionGeneration, - reportedOptions: { model: 'default' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + reportedOptions: { model: 'default' } }) } -function exit(child: ReturnType, reason: string, startupUnproven?: true) { +function exit(child: ReturnType, reason: string) { return host.handleAdapterEvent({ type: 'ended', ...child, reason, // As an adapter reports it: the exit's stderr as a log detail. failure: { kind: 'providerExited', detail: { text: reason, audience: 'log' } }, - cause: 'unexpected-exit', - ...(startupUnproven ? { startupUnproven } : {}) + cause: 'unexpected-exit' }) } @@ -405,7 +404,7 @@ describe('a published child that dies while it proves its start', () => { await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) const child = currentChild() - await exit(child, EXIT, true) + await exit(child, EXIT) await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) expect(await statusRows()).toEqual([ @@ -451,7 +450,7 @@ describe('a start another operation made that dies while a sent message waits on // Accepted first; that child's exit is settled before the loop's first step. const sent = host.send(CALLER, params) - const exited = exit(operationChild, EXIT, true) + const exited = exit(operationChild, EXIT) expect(await sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } @@ -480,7 +479,7 @@ describe('a start another operation made that dies while a sent message waits on await restartHost() acquire.mockImplementationOnce(spawnStartingChild) await startForOperation() - await exit(currentChild(), EXIT, true) + await exit(currentChild(), EXIT) expect(await statusRows()).toHaveLength(1) const id = await accept('after the failure') @@ -513,7 +512,7 @@ describe('a start another operation made that dies while a sent message waits on // A second operation's start lands after the first child's exit, before the loop's first step. const sent = host.send(CALLER, params) - const exited = exit(currentChild(), EXIT, true) + const exited = exit(currentChild(), EXIT) const held = startForOperation() await sent await exited @@ -530,14 +529,15 @@ describe('a start another operation made that dies while a sent message waits on describe('a child that ends before its message is handed over', () => { it('starts one child for the message, then rejects it and stops (R2)', async () => { await restartHost() - // The child the loop starts exits as its start step returns: that exit, asked for on the lane - // then, lands before the handover step. + // The child the loop starts proves its start, then exits as its start step returns: that exit, + // asked for on the lane then, lands before the handover step. acquire.mockImplementation(async (input) => { - const child = await spawnChild(input) + const child = await spawnStartingChild(input) const { acquisitionGeneration } = child if (acquisitionGeneration === undefined) { throw new Error('spawnChild always names a generation') } + void starts.prove({ sessionId: SESSION, fence: input.fence, acquisitionGeneration }) void host.handleAdapterEvent({ type: 'ended', sessionId: SESSION, @@ -558,12 +558,12 @@ describe('a child that ends before its message is handed over', () => { await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) // The exit's stderr rides beside the sentence, never in it. const failure = { - kind: 'providerExited', + kind: 'providerStartFailed', detail: { text: 'codex app-server crashed', audience: 'log' } } expect(await submission(id)).toMatchObject({ - reason: 'Codex stopped before this message was sent.', - rejection: failure + reason: 'Codex stopped before it finished starting. Send your message to try again.', + rejection: { ...failure, kind: 'providerStartFailed' } }) expect(await statusRows()).toEqual([ { itemId: expect.any(String), text: (await submission(id))?.reason, tone: 'error', failure } @@ -592,8 +592,11 @@ describe('another child indexed after a Stop ended the starting one', () => { const replacement = currentChild() expect(replacement.acquisitionGeneration).not.toBe(stopped.acquisitionGeneration) const secondId = await accept('second') - await settleLoop() expect((await submission(secondId))?.handedOverAt).toBeUndefined() + expect(dispatch).not.toHaveBeenCalled() + await host.flushStreamedEvents(SESSION) + expect((await submission(secondId))?.handedOverAt).toBeUndefined() + expect(dispatch).not.toHaveBeenCalled() await prove() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts new file mode 100644 index 000000000000..981636f2caad --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts @@ -0,0 +1,21 @@ +import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' +import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' +import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' + +export type StructuredAgentSessionProviderOpenInput = { + sessionId: string + fence: number + acquisitionGeneration: string | null + signal: AbortSignal +} + +/** Protocol facts returned to the host that awaits this child's open. */ +export type StructuredAgentSessionProviderOpenResult = { + link?: AgentSessionProviderHandleLink + reportedOptions: AgentSessionOptionsResult['current'] + catalogListing?: AgentModelCatalogLiveListing +} + +export type StructuredAgentSessionProviderOpen = ( + input: StructuredAgentSessionProviderOpenInput +) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts new file mode 100644 index 000000000000..fbc8ba797193 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -0,0 +1,59 @@ +import { expect, it, vi } from 'vitest' +import type { + StructuredAgentSessionProviderOpen, + StructuredAgentSessionProviderOpenResult +} from './structured-agent-session-provider-open-contract' +import { + CALLER, + attach, + attachParams, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { hostTestMessage } from './structured-agent-session-host-test-data' + +const OPENED: StructuredAgentSessionProviderOpenResult = { + reportedOptions: { model: 'gpt-test' } +} + +function awaitOpen() { + const state = hostTestState() + const acquire = state.acquire.getMockImplementation() + if (!acquire) { + throw new Error('The host fixture has no spawn implementation.') + } + state.host.deps.adapter.acquire = async (input) => ({ + ...(await acquire(input)), + providerChildPhase: 'starting', + acquisitionGeneration: 'awaited-open' + }) + const pending = Promise.withResolvers() + const open = vi.fn(() => pending.promise) + const close = vi.fn(async () => true) + state.host.deps.adapter.open = open + state.host.deps.adapter.closeSession = close + const send = (text: string) => { + const body = hostTestMessage(text) + return state.host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + } + return { ...state, open, close, pending, send } +} + +it('cancels the open on quit after a second client attach is refused', async () => { + const { host, open, close, dispatch, pending, send } = awaitOpen() + await attach() + await send('held') + expect(await host.attach({ callerKey: 'second-client' }, attachParams())).toMatchObject({ + ok: false + }) + const ended = host.flushAllStreamedEvents({ trigger: 'quit' }) + try { + await vi.waitFor(() => expect(open.mock.calls[0]?.[0].signal.aborted).toBe(true)) + await ended + expect(close).toHaveBeenCalledTimes(1) + expect(dispatch).not.toHaveBeenCalled() + } finally { + pending.resolve(OPENED) + await ended + } +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts new file mode 100644 index 000000000000..9e9020fdb1fc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts @@ -0,0 +1,64 @@ +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' +import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' +import { + settleStructuredAgentSessionProviderStarted, + type StructuredAgentSessionProviderStartedContext +} from './structured-agent-session-provider-started' + +/** The host awaits protocol construction without holding the conversation's ordered steps. */ +export async function openStructuredAgentSessionProviderChild( + context: StructuredAgentSessionProviderStartedContext & { + failStartingChild: ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity, + error: unknown + ) => Promise + }, + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity +): Promise { + const open = context.deps.adapter.open + if (!open || !context.runtimeState.startupAttempts.onClock(sessionId, child)) { + return + } + const attempt = context.runtimeState.acquireAborts.begin(sessionId) + try { + attempt.signal.throwIfAborted() + const result = await waitForPromiseWithSignal( + open.call(context.deps.adapter, { + sessionId, + fence: child.fence, + acquisitionGeneration: child.generation, + signal: attempt.signal + }), + attempt.signal + ) + if (attempt.signal.aborted) { + return + } + await settleStructuredAgentSessionProviderStarted( + context, + { + ...result, + type: 'started', + sessionId, + fence: child.fence, + acquisitionGeneration: child.generation ?? '' + }, + attempt.signal + ) + } catch (error) { + // Stop, close, quit and expiry already own the child's end. + if (!attempt.signal.aborted) { + await context.failStartingChild(sessionId, child, error).catch((closeError: unknown) => + context.deps.logger.warn('closing a failed provider open did not finish', { + scope: 'provider-open-close', + sessionId, + error: closeError + }) + ) + } + } finally { + attempt.end() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts index 7543580e4304..a256f02037e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts @@ -28,8 +28,9 @@ let store: AgentSessionRecordStore let host: StructuredAgentSessionHost const providerStarted = vi.fn() -function startHost(phase: 'ready' | 'starting'): void { +function startHost(): void { const acquire: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -38,8 +39,7 @@ function startHost(phase: 'ready' | 'starting'): void { mintedAtFence: fence, observedAt: NOW }, - acquisitionGeneration: 'generation-1', - providerChildPhase: phase + acquisitionGeneration: 'generation-1' }) host = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, @@ -80,36 +80,34 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -it('a child that proves its start at acquire tells the catalog, with its record', async () => { - startHost('ready') +/** The adapter's report that the child's protocol session exists. */ +function proveStart(): Promise { + return host.handleAdapterEvent({ + type: 'started', + sessionId: SESSION, + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + acquisitionGeneration: 'generation-1', + reportedOptions: { model: 'sonnet' } + }) +} + +it('a published child tells the catalog only once its start is proven, with its record', async () => { + startHost() await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true }) + expect(providerStarted).not.toHaveBeenCalled() + await proveStart() expect(providerStarted).toHaveBeenCalledTimes(1) expect(providerStarted.mock.calls[0]?.[0]).toMatchObject({ sessionId: SESSION }) }) it('a re-attach to the live child proves nothing new and tells the catalog nothing', async () => { - startHost('ready') + startHost() const params = hostTestAttachParams(null) await expect(host.attach(CALLER, params)).resolves.toMatchObject({ ok: true }) + await proveStart() const fence = store.getRecord(SESSION)?.lease.runtimeFence // A reconnecting client replays its attach; the same operation re-attaches the live child. await expect(host.attach(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(fence) expect(providerStarted).toHaveBeenCalledTimes(1) }) - -it('a published child tells the catalog only once its start is proven', async () => { - startHost('starting') - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true }) - expect(providerStarted).not.toHaveBeenCalled() - await host.handleAdapterEvent({ - type: 'started', - sessionId: SESSION, - fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, - acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'sonnet' }, - restoreSkippedOptions: [], - optionRevision: 0 - }) - expect(providerStarted).toHaveBeenCalledTimes(1) -}) 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 e742f23fd58e..e36758ff177c 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 @@ -11,8 +11,7 @@ import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID, - claudeStartupSettled + PROVIDER_SESSION_ID } 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' @@ -135,17 +134,19 @@ function lastPhase(): string | undefined { } describe('a publish-first Claude create whose init is slow', () => { - it('never persists the catalog default, and persists the reported model once started', async () => { + it('keeps provider defaults out of saved picks after starting', async () => { await expect(host.attach(CALLER, claudeParams())).resolves.toMatchObject({ ok: true }) // Published, not yet answering: the record holds no model rather than a guessed one. expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() expect(lastPhase()).toBe('starting') - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) await Promise.all(lifecycle) - expect(store.getRecord(SESSION)?.options?.model).toBe('claude-opus-9') + expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() expect(lastPhase()).toBe('ready') }) @@ -156,7 +157,9 @@ describe('a publish-first Claude create whose init is slow', () => { ).resolves.toMatchObject({ ok: true }) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') @@ -166,7 +169,9 @@ describe('a publish-first Claude create whose init is slow', () => { it('keeps the picked model across a resume whose new child starts on its own default', async () => { const params = claudeParams() await host.attach(CALLER, { ...params, options: { model: 'opus' } }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) await Promise.all(lifecycle) await host.close(SESSION, 'evict') const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -180,7 +185,9 @@ describe('a publish-first Claude create whose init is slow', () => { expect(store.getRecord(SESSION)?.options?.model).toBe('opus') expect(lastPhase()).toBe('starting') - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') @@ -190,7 +197,9 @@ describe('a publish-first Claude create whose init is slow', () => { await host.attach(CALLER, claudeParams()) expect(savedListings).toEqual([]) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) await Promise.all(lifecycle) // `started` hands over the listing; the settings readback after it, what the config resolved. @@ -219,9 +228,11 @@ describe('a publish-first Claude create whose init is slow', () => { sessionId: SESSION, listing: expect.objectContaining({ models: LISTED }) }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) await Promise.all(lifecycle) - expect(store.getRecord(SESSION)?.options?.model).toBe('claude-opus-9') + expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() expect(savedListings.at(-1)).toEqual({ sessionId: SESSION, listing: expect.objectContaining({ @@ -241,7 +252,9 @@ describe('a publish-first Claude create whose init is slow', () => { return proveOwner(input) } await expect(host.attach(CALLER, claudeParams())).resolves.toMatchObject({ ok: true }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) await Promise.all(lifecycle) await vi.waitFor(() => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index 553015985e69..e0e6c3130027 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -1,34 +1,27 @@ -// The host's half of a provider child proving its start. -// -// A publish-first acquire hands the host a child that has answered nothing yet, so the record -// keeps only the saved options the reservation carried, and the delivery loop hands it nothing. -// This is where the host learns the start landed: the child turns `ready`, its startup attempt -// ends, and the loop wakes to hand over what was queued meanwhile. Only once that handover is done -// is what the child reports persisted, in a step of its own, so bookkeeping never sits between a -// ready child and the user's first message; a failed write is reported, never thrown. Every report -// is persisted only if no pick or later report came after its read (`option-revisions`). -// -// These run under the session's own serialized steps, which its close and sends wait on, so they -// ask the provider nothing: the event carries what the child proved. +// Opening records the provider handle and recovers history before delivery; reports never replace picks. -import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' import type { StructuredAgentSessionOptionsReportedEvent, - StructuredAgentSessionOptionsSkippedEvent, StructuredAgentSessionStartedEvent } from './structured-agent-session-adapter' import type { StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' -import { nativeSessionOptionsFromReport } from './structured-agent-session-option-restoration' import { markProviderChildStarted, - sameProviderChild + sameProviderChild, + structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import type { StructuredAgentSessionOptionRevisions } from './structured-agent-session-option-revisions' -import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' +import { + saveStructuredAgentSessionListing, + saveStructuredAgentSessionStartReadbackListing +} from './structured-agent-session-start-listing' import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' +import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' +import { recordAgentSessionProviderHandle } from '../../runtime/agent-session-provider-handle-transition' +import { recoverStructuredRewind } from './structured-rewind-recovery' export type StructuredAgentSessionProviderStartedContext = { deps: StructuredAgentSessionHostDeps @@ -37,47 +30,121 @@ export type StructuredAgentSessionProviderStartedContext = { now: () => number publishStatus?: (sessionId: string) => void runtimeState: { - startupAttempts: Pick - optionRevisions: Pick< - StructuredAgentSessionOptionRevisions, - 'admitReport' | 'advance' | 'isNewest' - > + startupAttempts: Pick + acquireAborts: Pick } /** The barrier lifts: what was accepted while the child started is handed over now. Settles once * the loop has handed over all it can. */ wakeDelivery: (sessionId: string) => Promise + /** Ends a starting child whose start cannot be completed, as its startup limit would. */ + stopStartingChild: (sessionId: string, child: StructuredAgentSessionProviderChildIdentity) => void } -type ReportedOptions = Omit - export async function settleStructuredAgentSessionProviderStarted( context: StructuredAgentSessionProviderStartedContext, - event: StructuredAgentSessionStartedEvent + event: StructuredAgentSessionStartedEvent, + signal?: AbortSignal ): Promise { - // On arrival, before any wait: reports are admitted in the order the child made them. - const admitted = admitReportedOptions(context, event) // Serialized behind the attach that published this child, so the lease it proved is committed. - const started = await context.serialize(event.sessionId, async () => { + await context.serialize(event.sessionId, async () => { + signal?.throwIfAborted() const session = context.sessions.get(event.sessionId) const child = { generation: event.acquisitionGeneration, fence: event.fence } - // A stale child's proof starts nothing: the barrier stays on the child the host holds. - if (!session || !markProviderChildStarted(session, child)) { + // A stale child's proof starts nothing: the barrier stays on the child the host holds. Nor + // does one that lost the race to its startup limit: the limit's stop is queued behind this. + if ( + !session?.child || + session.child.phase !== 'starting' || + !sameProviderChild(session.child, child) || + !context.runtimeState.startupAttempts.onClock(event.sessionId, child) + ) { + return null + } + if (!(await recordStartedHandle(context, event))) { + context.stopStartingChild(event.sessionId, child) + return null + } + if (!(await recoverRewindAtStart(context, event.sessionId, session, signal))) { + context.stopStartingChild(event.sessionId, child) + return null + } + // The limit can pass while recovery ran; its stop ends this child next. + if ( + !context.runtimeState.startupAttempts.onClock(event.sessionId, child) || + !markProviderChildStarted(session, child) + ) { return null } context.runtimeState.startupAttempts.ready(event.sessionId, child) - const delivered = context.wakeDelivery(event.sessionId) + void context.wakeDelivery(event.sessionId) noteStructuredAgentSessionProviderStarted(context.deps, event.sessionId) if (event.catalogListing) { - context.deps.modelCatalog?.recordLiveListing(event.sessionId, event.catalogListing) + saveStructuredAgentSessionListing(context, event.sessionId, event.catalogListing) } context.publishStatus?.(event.sessionId) - return { delivered } + return undefined }) - if (!started) { - return +} + +/** A rewind left in doubt is settled by the first child that can read the provider's history: + * this one, now that its protocol session is open, before it is handed anything a recovered + * rewind would move. One that stays in doubt fails the start, as it would refuse every send. */ +async function recoverRewindAtStart( + context: StructuredAgentSessionProviderStartedContext, + sessionId: string, + session: StructuredAgentSessionHostSession, + signal?: AbortSignal +): Promise { + try { + await recoverStructuredRewind( + context.deps, + sessionId, + session.journal, + structuredAgentSessionConversationFence(context.deps.store, sessionId), + context.deps.adapter, + context.now, + undefined, + signal + ) + return true + } catch (error) { + if (signal?.aborted) { + return false + } + context.deps.logger.warn('settling a rewind in doubt at the start failed', { + scope: 'rewind-recovery', + sessionId, + error + }) + return false + } +} + +/** The handle the child's protocol session answered with, recorded before it is handed anything: + * without it a later start could not resume what this child ran. False when there is none. */ +async function recordStartedHandle( + context: StructuredAgentSessionProviderStartedContext, + event: StructuredAgentSessionStartedEvent +): Promise { + const { store, logger } = context.deps + const { link } = event + if (!link) { + // Only an acquisition that already held its handle may prove its start without one. + return store.getRecord(event.sessionId)?.lease.provenHandleLinkId != null + } + try { + await store.transitionHandoff(event.sessionId, (record) => + recordAgentSessionProviderHandle({ record, fence: event.fence, link, now: context.now() }) + ) + return true + } catch (error) { + logger.warn('recording the handle a started provider answered with failed', { + scope: 'provider-started-handle', + sessionId: event.sessionId, + error + }) + return false } - // Not awaited: the adapter's next event may be what the handover itself waits on. - void started.delivered.then(() => persistReportedOptions(context, event, admitted)) } /** A proven start shows the agent's program exists and may mean a sign-in was fixed, so the @@ -94,144 +161,22 @@ export function noteStructuredAgentSessionProviderStarted( /** What a ready child reports later, such as a read that came after its start: persisted as the * start's report is, never ahead of a send. */ -export function settleStructuredAgentSessionOptionsReported( +export async function settleStructuredAgentSessionOptionsReported( context: StructuredAgentSessionProviderStartedContext, event: StructuredAgentSessionOptionsReportedEvent ): Promise { - const admitted = admitReportedOptions(context, event) - if (event.catalogListing) { - void saveStartReadbackListing(context, event, event.catalogListing) - } - return persistReportedOptions(context, event, admitted) -} - -/** A start's late readback (Claude's settings) says once what the config resolved. Serialized - * behind the attach that published the child, which may still be indexing it. */ -function saveStartReadbackListing( - context: StructuredAgentSessionProviderStartedContext, - event: ReportedOptions, - listing: AgentModelCatalogLiveListing -): Promise { - return context - .serialize(event.sessionId, async () => { - const child = context.sessions.get(event.sessionId)?.child - const reporter = { generation: event.acquisitionGeneration, fence: event.fence } - if (child && sameProviderChild(child, reporter)) { - context.deps.modelCatalog?.recordLiveListing(event.sessionId, listing) - } - }) - .catch((error: unknown) => { - context.deps.logger.warn('saving what a started provider resolved failed', { - scope: 'provider-started-catalog', - sessionId: event.sessionId, - error - }) - }) -} - -function admitReportedOptions( - context: StructuredAgentSessionProviderStartedContext, - event: ReportedOptions -): number | null { - return context.runtimeState.optionRevisions.admitReport(event.sessionId, event.optionRevision) -} - -/** Non-fatal, and only for the child that reported, while its report is still the newest word: a - * pick or a later report since it was admitted is newer than what it read. */ -function persistReportedOptions( - context: StructuredAgentSessionProviderStartedContext, - event: ReportedOptions, - admitted: number | null -): Promise { - if (admitted === null) { - return Promise.resolve() - } - return context - .serialize(event.sessionId, async () => { - const child = context.sessions.get(event.sessionId)?.child - const record = context.deps.store.getRecord(event.sessionId) - if ( - !child || - !sameProviderChild(child, { - generation: event.acquisitionGeneration, - fence: event.fence - }) || - !record || - record.lease.runtimeFence !== event.fence || - !agentSessionLeaseAdmitsWriter(record.lease) || - !context.runtimeState.optionRevisions.isNewest( - event.sessionId, - event.optionRevision, - admitted - ) - ) { - return - } - try { - await context.deps.store.replaceSessionOptions({ - sessionId: event.sessionId, - fence: event.fence, - options: nativeSessionOptionsFromReport({ - reported: event.reportedOptions, - restoreSkipped: event.restoreSkippedOptions, - ...(event.retiredOptions ? { retired: event.retiredOptions } : {}), - ...(record.options ? { priorOptions: record.options } : {}) - }), - now: context.now() - }) - } finally { - context.publishStatus?.(event.sessionId) - } + const child = context.sessions.get(event.sessionId)?.child + if (child?.phase === 'starting') { + const readiness = context.runtimeState.startupAttempts.readiness(event.sessionId, { + generation: event.acquisitionGeneration, + fence: event.fence }) - .catch((error: unknown) => { - context.deps.logger.warn('recording what a started provider reported failed', { - scope: 'provider-started-options', - sessionId: event.sessionId, - error - }) - }) -} - -/** A running child showed saved options it cannot run: the record drops them, as a start that - * skipped them would, so the next start runs the provider's own. Reported, never thrown. */ -export function settleStructuredAgentSessionOptionsSkipped( - context: StructuredAgentSessionProviderStartedContext, - event: StructuredAgentSessionOptionsSkippedEvent -): Promise { - // A report read before the child showed this is out of date: it would write the value back. - context.runtimeState.optionRevisions.advance(event.sessionId) - return context.serialize(event.sessionId, async () => { - const { store } = context.deps - const record = store.getRecord(event.sessionId) - if ( - !record?.options || - record.lease.runtimeFence !== event.fence || - !agentSessionLeaseAdmitsWriter(record.lease) - ) { + if (!readiness || (await readiness.wait()) !== 'ready') { return } - const options = { ...record.options } - for (const [key, value] of Object.entries(event.options)) { - // A pick made since the child launched is the user's, whatever the child showed. - if (options[key] === value) { - delete options[key] - } - } - try { - await store.replaceSessionOptions({ - sessionId: event.sessionId, - fence: event.fence, - options, - now: context.now() - }) - } catch (error) { - context.deps.logger.warn('dropping a saved option the provider cannot run failed', { - scope: 'provider-options-skipped', - sessionId: event.sessionId, - error - }) - } finally { - context.publishStatus?.(event.sessionId) - } - }) + } + if (event.catalogListing) { + void saveStructuredAgentSessionStartReadbackListing(context, event, event.catalogListing) + } + return undefined } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts index 00665c2fc3d3..83b98e806eec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts @@ -66,17 +66,13 @@ function writeUnreadableReceipt(id: string, method: string): void { .run(id, SESSION, CALLER.callerKey, method) } -/** What a rewind does to the conversation: a new epoch rebuilt with none of the old rows. */ +/** Simulates an older host retiring the whole epoch, including its unsent submissions. */ async function rewindConversation(): Promise { const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal if (!journal) { throw new Error('expected an open conversation') } - await journal.replaceEpochItems( - 'handle_forked', - rig.store.getRecord(SESSION)!.lease.runtimeFence, - [] - ) + await journal.rollEpoch('handle_forked', rig.store.getRecord(SESSION)!.lease.runtimeFence) } /** A stop that leaves a waiting card paused, which only a Resume lifts. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts index c75bd129d7b3..d27770589aaf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts @@ -27,7 +27,8 @@ let rig: QueuedMessageTestRig beforeEach(async () => { vi.useFakeTimers() - rig = await createQueuedMessageTestRig() + // A Stop ends the agent, so the card's run resumes the conversation in a new one. + rig = await createQueuedMessageTestRig({ restartable: true }) }) afterEach(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts index 86fff5a7b973..10a720c8ce52 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts @@ -2,6 +2,8 @@ // the events it writes back as the provider would. import { vi, type Mock } from 'vitest' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { activeProviderContext } from '../../../shared/agent-session-provider-context' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' @@ -29,7 +31,8 @@ export type QueuedRigProviderOptions = { export function createQueuedRigProvider( store: Pick, - options: QueuedRigProviderOptions + options: QueuedRigProviderOptions, + host: () => StructuredAgentSessionHost ) { // Admitted: the message is written and unanswered, so the session owes work // until the test settles it. @@ -53,7 +56,7 @@ export function createQueuedRigProvider( ) let events: StructuredAgentSessionEventSink | undefined - const adapter: StructuredAgentSessionAdapter = { + const scripted: StructuredAgentSessionAdapter = { acquire: async ({ identity, fence, spawnToken, events: sink }) => { starts() const hold = startHold @@ -72,6 +75,7 @@ export function createQueuedRigProvider( ? `${THREAD}-context-${record!.providerContextBoundary!.operationId}` : (context?.head?.handle.nativeId ?? THREAD) return { + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, @@ -104,6 +108,8 @@ export function createQueuedRigProvider( ...(options.recoverRewind ? { recoverRewind: options.recoverRewind } : {}) } + const adapter = options.starting ? scripted : startsWhenPublished(scripted, host) + /** What the provider's translator writes when a /compact's turn ends, as a success. */ function finishCompact(): void { const { command } = compact.mock.calls.at(-1)![0] 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 ef7e36953bd3..0d4c821007f8 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 @@ -54,7 +54,7 @@ export async function createQueuedMessageTestRig( const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-')) resetHostTestOperationIds() const store = await openTestAgentSessionRecordStore(root) - const provider = createQueuedRigProvider(store, options) + const provider = createQueuedRigProvider(store, options, () => host) const { dispatch, compact, cancelTurn, closeSession } = provider const makeHost = () => new StructuredAgentSessionHost({ @@ -263,9 +263,7 @@ export async function createQueuedMessageTestRig( sessionId: SESSION, fence: store.getRecord(SESSION)!.lease.runtimeFence, acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'default' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + reportedOptions: { model: 'default' } }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index ffe732bfbbdd..45172d6d58ef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -40,7 +40,8 @@ let store: QueuedMessageTestRig['store'] let dispatch: QueuedMessageTestRig['dispatch'] beforeEach(async () => { - rig = await createQueuedMessageTestRig() + // A Stop ends the agent's session, so the next send starts a child that resumes the thread. + rig = await createQueuedMessageTestRig({ restartable: true }) ;({ host, store, dispatch } = rig) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts new file mode 100644 index 000000000000..f293632cecfb --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts @@ -0,0 +1,58 @@ +import { expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + CALLER, + attach, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' + +it.each(['send', 'compact'] as const)( + 'answers an accepted %s receipt during an unconfirmed rewind without starting an agent', + async (command) => { + const { host, store, acquire, dispatch } = hostTestState() + const compact = vi.fn(async () => ({ state: 'admitted' as const })) + host.deps.adapter.compact = compact + const providerWrites = command === 'send' ? dispatch : compact + await attach() + const body = hostTestMessage('already accepted') + const params = { + envelope: envelope( + command === 'send' ? 'agentSession.send' : 'agentSession.conversationCommand', + command === 'send' ? { body } : { command: 'compact' } + ), + body, + command: 'compact' as const + } + const run = (caller = CALLER) => + command === 'send' ? host.send(caller, params) : host.conversationCommand(caller, params) + expect(await run()).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(providerWrites).toHaveBeenCalledTimes(1)) + await host.close(SESSION, 'evict') + const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + rewind: { + operationId: envelope('agentSession.rewind', {}).clientOperationId, + callerKey: CALLER.callerKey, + itemId: agentJournalSubmissionKey(params.envelope.clientOperationId), + providerItemId: 'codex:thread-1:turn-1:1', + expectedEpoch, + phase: 'prepared', + retained: [] + } + })) + acquire.mockRejectedValueOnce(new Error('restart failed')) + + expect(await run({ callerKey: 'reconnecting-phone' })).toMatchObject({ + ok: true, + replayed: true + }) + expect(acquire).toHaveBeenCalledTimes(1) + expect(providerWrites).toHaveBeenCalledTimes(1) + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts new file mode 100644 index 000000000000..de87449c26dd --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts @@ -0,0 +1,264 @@ +import { + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from '../../../shared/agent-session-mutation-envelope' +import { refuse, refuseUnclassified } from '../../../shared/agent-session-wire-refusals' +import type { + AgentSessionMutationEnvelope, + AgentSessionMutationResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' +import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' +import { sameProviderChild } from './structured-agent-session-provider-child' +import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from './structured-agent-session-send-settlement' +import { + openForWrite, + openForProviderWrite, + rewindInDoubt +} from './structured-agent-session-send-preparation' +import { agentSessionOperationOutcomeUnknown } from './structured-agent-session-replay-outcome' +import type { AgentSessionMutationSessionPreparation } from './structured-agent-session-mutation-admission' +import type { MutationPlan } from './structured-agent-session-mutation-plans' +import { commandReceiptScope } from '../agent-session-journal/command-receipt-schema' +import { parseAgentSessionOperationTimestamp } from '../../../shared/agent-session-host-authority' +import { waitForStructuredAgentSessionStart } from './structured-agent-session-start-readiness' +import type { StructuredAgentSessionDeliveryPlace } from './structured-agent-session-delivery-order' +import type { StructuredAgentSessionStartReadiness } from './structured-agent-session-startup-attempt' + +type ReadyOperationPlan = Pick< + MutationPlan, + 'method' | 'fields' | 'operationIdScope' | 'commandReceipt' +> +type QueuedReadyOperation = + | { result: AgentSessionMutationResult } + | { + child: StructuredAgentSessionProviderChildIdentity + readiness: StructuredAgentSessionStartReadiness + place: StructuredAgentSessionDeliveryPlace + } + +export type ReadyOperationChild = StructuredAgentSessionProviderChildIdentity | null +export type ReadyOperationSelection = { + ok: true + child: ReadyOperationChild + replay?: true + deadline?: number +} + +/** A send or conversation command waits only when an earlier rewind needs its provider. */ +export async function runAfterRewindStart( + context: StructuredAgentSessionMutationContext, + callerKey: string, + envelope: AgentSessionMutationEnvelope, + plan: ReadyOperationPlan, + run: (selection: ReadyOperationSelection) => Promise> +): Promise> { + return runWithReadyStructuredAgentSessionOperation(context, callerKey, envelope, plan, run, { + needsAgent: rewindInDoubt + }) +} + +export function operationDidNotRun(expired = false): { + ok: false + refusal: AgentSessionWireRefusal +} { + return { + ok: false, + refusal: expired + ? refuse( + 'agent_session_operation_expired', + { reason: 'operationExpired' }, + 'The operation did not run because its wait expired. Try again.' + ) + : refuseUnclassified( + 'agent_session_owner_restart_failed', + 'The operation did not run because the agent is not ready.' + ) + } +} + +/** A ready child runs in this queue turn; only a starting child releases it to wait. */ +export async function runWithReadyStructuredAgentSessionOperation( + context: StructuredAgentSessionMutationContext, + callerKey: string, + envelope: AgentSessionMutationEnvelope, + plan: ReadyOperationPlan, + run: (selection: ReadyOperationSelection) => Promise>, + options: { + beforeAgent?: ( + record: AgentSessionRecord | null + ) => AgentSessionMutationSessionPreparation | null + needsAgent?: (record: AgentSessionRecord | null) => boolean + runAfterAgentEnds?: true + } = {} +): Promise> { + const hostFingerprint = computeAgentSessionPayloadFingerprint({ + method: plan.method, + sessionId: envelope.sessionId, + fields: plan.fields + }) + const conflict = agentSessionFingerprintConflict(envelope, hostFingerprint) + if (conflict) { + return { ok: false, refusal: conflict } + } + const deadline = Date.now() + STRUCTURED_AGENT_SESSION_START_WAIT_MS + const selected = await context.serialize>( + envelope.sessionId, + async () => { + const record = context.deps.store.getRecord(envelope.sessionId) + if (options.needsAgent && !options.needsAgent(record)) { + return { result: await run({ ok: true, child: null }) } + } + if (plan.commandReceipt) { + if (parseAgentSessionOperationTimestamp(envelope.clientOperationId) === null) { + return { result: await run({ ok: true, child: null }) } + } + // Receipt admission owns replay and unreadable-result answers, before any start. + let recorded: boolean + try { + recorded = + context.deps.store.readCommandReceipt( + commandReceiptScope(callerKey, plan.operationIdScope), + envelope.clientOperationId + ).verdict !== 'absent' + } catch (error) { + context.deps.logger.warn('reading a command receipt failed', { + scope: 'command-receipt-read', + sessionId: envelope.sessionId, + operationId: envelope.clientOperationId, + error + }) + return { + result: { + ok: false as const, + refusal: agentSessionOperationOutcomeUnknown(envelope.clientOperationId) + } + } + } + if (recorded) { + return { result: await run({ ok: true, child: null }) } + } + } else { + const evaluated = context.deps.store.evaluateMutationOperation({ + callerKey, + envelope, + hostFingerprint, + now: context.now(), + ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}) + }) + if (!evaluated || evaluated.decision.decision !== 'admit') { + return { + result: await run({ + ok: true, + child: null, + ...(evaluated?.decision.decision === 'replay' ? { replay: true as const } : {}) + }) + } + } + } + const select = async () => { + const opened = await openForWrite(context, envelope)() + if (!opened.ok) { + return opened + } + const checked = options.beforeAgent?.(context.deps.store.getRecord(envelope.sessionId)) + if (checked && !checked.ok) { + return checked + } + const ensured = await context.ensureAgent(envelope.sessionId) + if (!ensured.ok) { + return ensured + } + const child = context.sessions.get(envelope.sessionId)?.child + if (!child || child.close) { + return operationDidNotRun() + } + const identity = { generation: child.generation, fence: child.fence } + if (child.phase === 'ready') { + return { ok: true as const, child: identity, deadline } + } + const readiness = context.startupAttempts.readiness(envelope.sessionId, identity) + return readiness + ? { ok: true as const, child: identity, readiness } + : options.runAfterAgentEnds + ? { ok: true as const, child: identity } + : operationDidNotRun() + } + const child = await select().catch((error: unknown) => { + context.deps.logger.warn('selecting an agent for an operation failed', { + scope: 'operation-agent-start', + sessionId: envelope.sessionId, + error + }) + return operationDidNotRun() + }) + if (!child.ok) { + return { result: child } + } + if (!child.readiness) { + return { result: await run(child) } + } + context.deliveryOrder.reconcile( + envelope.sessionId, + context.sessions.get(envelope.sessionId)?.journal.submissions() ?? [] + ) + const place = context.deliveryOrder.reserve(envelope.sessionId) + return { + child: child.child, + readiness: child.readiness, + place + } + } + ) + if ('result' in selected) { + return selected.result + } + try { + const ready = selected.readiness.wait() + const outcome = await waitForStructuredAgentSessionStart( + Promise.all([ready, selected.place.before]).then(([outcome]) => + outcome === 'ready' ? 'ready' : 'ended' + ), + deadline - Date.now() + ) + if (outcome !== 'ready' && !(outcome === 'ended' && options.runAfterAgentEnds)) { + return operationDidNotRun(outcome === 'timeout') + } + if (Date.now() >= deadline) { + return operationDidNotRun(true) + } + return await context.serialize(envelope.sessionId, () => + run({ ok: true, child: selected.child, deadline }) + ) + } finally { + selected.place.release() + } +} + +/** Replay uses the ledger answer; a new operation uses only its selected ready child. */ +export function openWithReadyStructuredAgentSessionChild( + context: StructuredAgentSessionMutationContext, + envelope: AgentSessionMutationEnvelope, + selection: ReadyOperationSelection +): (ledger: 'admit' | 'replay') => Promise { + return async (ledger) => { + const opened = await openForProviderWrite(context, envelope)() + if (!opened.ok || ledger === 'replay') { + return opened + } + if (!selection.child) { + return selection.replay + ? { ok: false, refusal: agentSessionOperationOutcomeUnknown(envelope.clientOperationId) } + : operationDidNotRun() + } + if (selection.deadline !== undefined && Date.now() >= selection.deadline) { + return operationDidNotRun(true) + } + const child = context.sessions.get(envelope.sessionId)?.child + return child?.phase === 'ready' && !child.close && sameProviderChild(child, selection.child) + ? opened + : operationDidNotRun() + } +} 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 01639485bd25..e443c55b9eb8 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,7 +30,8 @@ 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' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' +import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' import { AgentSessionJournal } from '../agent-session-journal/journal-store' const CALLER = { callerKey: 'client-1' } @@ -64,34 +65,38 @@ async function createHarness(options: { attached?: boolean } = {}) { const root = await mkdtemp(join(tmpdir(), 'orca-refusal-oracle-')) const store = await openTestAgentSessionRecordStore(root) const setOption = vi.fn(async () => undefined) - const adapter: StructuredAgentSessionAdapter = { - acquire: async ({ fence }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW - 1_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - }), - dispatch: async () => ({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => undefined, - // A failed acquisition is proven gone, as the real adapters prove it. - releaseAcquisition: async () => true, - setOption - } + const adapter: StructuredAgentSessionAdapter = startsWhenPublished( + { + acquire: async ({ fence }) => ({ + providerChildPhase: 'starting' as const, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW - 1_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: async () => ({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => undefined, + // A failed acquisition is proven gone, as the real adapters prove it. + releaseAcquisition: async () => true, + setOption + }, + () => host + ) const host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter, 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 7bb119ea22ba..f7e6388f92e2 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 @@ -10,7 +10,6 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journa import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { agentSessionRefusalOperationState } from '../../../shared/agent-session-refusal-retry' import type { AgentSessionThreadGoalChange } from '../../../shared/agent-session-wire' -import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' @@ -27,6 +26,7 @@ import { } from './structured-agent-session-host-test-data' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' +import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' const CALLER = { callerKey: 'client-1' } @@ -47,30 +47,39 @@ 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, events }) => { - providerEvents = events - return { - 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 + const adapter: StructuredAgentSessionAdapter = startsWhenPublished( + { + acquire: async ({ fence, spawnToken, events }) => { + providerEvents = events + return { + providerChildPhase: 'starting' as const, + 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 }, - 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 + ) host = new StructuredAgentSessionHost({ agents: claudeAndCodexAgents(adapter), logger: createStructuredAgentSessionLogger(), @@ -99,23 +108,9 @@ beforeEach(async () => { }, now: () => NOW }) - // One live task a background Stop reaches. - host.deps.statusSink = { publish: () => {}, forget: () => {}, readChildWork: () => [TASK] } expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) }) -const TASK: AgentChildWorkView = { - id: 'child-task-1', - providerId: 'task-1', - kind: 'agent', - state: 'working', - membership: 'live', - firstObservedAt: 1, - observedAt: 1, - stoppable: true, - invocation: { invocationId: 'spawn-task-1', generation: 1 } -} - /** The provider's running turn, which a Stop naming it reaches. */ async function runningTurn(): Promise { providerEvents?.appendItem( @@ -165,9 +160,11 @@ describe('a press sent again', () => { expect(await pick('A', first)).toMatchObject({ ok: true, replayed: true }) expect(setOption.mock.calls.map(([input]) => input.value)).toEqual(['A', 'B']) + expect(store.getRecord(SESSION)?.options?.model).toBe('B') expect(await pick('A')).toMatchObject({ ok: true, replayed: false }) expect(setOption.mock.calls.map(([input]) => input.value)).toEqual(['A', 'B', 'A']) + expect(store.getRecord(SESSION)?.options?.model).toBe('A') }) it('leaves a cleared goal cleared under the first id, and sets it under a new one', async () => { 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 639cdc9b3ae7..21e35eebb9a8 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 @@ -22,6 +22,7 @@ import type { } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' +import { structuredAgentSessionHeldStarts } from './structured-agent-session-held-start.test-support' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { HOST_TEST_NOW, @@ -66,6 +67,11 @@ export type RestTestRig = { forget: Mock readChildWork: Mock<(subject: unknown) => AgentChildWorkView[]> } + /** Wraps an acquire so its child stays `starting`: every other child proves its start once + * published. */ + heldStart: ( + acquire: StructuredAgentSessionAdapter['acquire'] + ) => StructuredAgentSessionAdapter['acquire'] /** Opens a fresh host over the same store and journals: what a restart leaves behind. */ restart: (deps?: Partial) => Promise dispose: () => Promise @@ -163,18 +169,19 @@ export async function createRestTestRig( startUnavailable: vi.fn(() => undefined), readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })) } + const starts = structuredAgentSessionHeldStarts(() => rig.host) const hostFor = (overrides: Partial) => new StructuredAgentSessionHost({ agents: claudeAndCodexAgents(), logger: createStructuredAgentSessionLogger(), store, - adapter: { + adapter: starts.wrap({ ...adapter, releaseAcquisition: vi.fn(async () => true), cancelTurn: async () => ({ cancelled: true }), answerPrompt: async ({ commit }) => commit(), setOption: async () => undefined - }, + }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', @@ -193,6 +200,7 @@ export async function createRestTestRig( clock, statusEvents, sink, + heldStart: starts.held, restart: async (overrides = {}) => { await rig.host.flushAllStreamedEvents().catch(() => undefined) store = await openTestAgentSessionRecordStore(root) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-context-clear.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-context-clear.test.ts index 09e48d3a3ef2..d52763257607 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-context-clear.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-context-clear.test.ts @@ -58,7 +58,7 @@ describe('restart offers after context clear', () => { sessionId: SESSION, session: { journal: journal([turnItem('old', 'running'), clear], [unknown]), - child: { fence: 2 } + child: { fence: 2, phase: 'ready' } }, getRecord: value, childWork: () => undefined, @@ -78,7 +78,7 @@ describe('restart offers after context clear', () => { const turn = { ...turnItem('new-turn', 'running'), sequence: 6 } const offer = structuredAgentSessionWorkingAtStop({ sessionId: SESSION, - session: { journal: journal([clear, turn]), child: { fence: 2 } }, + session: { journal: journal([clear, turn]), child: { fence: 2, phase: 'ready' } }, getRecord: () => currentRecord, childWork: () => undefined, trigger: 'quit', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index bc88fe1c4152..1f0af01a1c12 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -51,7 +51,13 @@ describe('deriving what was working at teardown', () => { it('marks a session this host was running a turn for', () => { const markers = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] + [ + SESSION, + { + journal: journal([turnItem('turn-1', 'running')]), + child: { fence: 1, phase: 'ready' as const } + } + ] ]), getRecord: () => record(), childWork: () => undefined, @@ -78,7 +84,13 @@ describe('deriving what was working at teardown', () => { it('carries the update trigger so the surface can say the restart was not the user choice', () => { const [recorded] = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] + [ + SESSION, + { + journal: journal([turnItem('turn-1', 'running')]), + child: { fence: 1, phase: 'ready' as const } + } + ] ]), getRecord: () => record(), childWork: () => undefined, @@ -93,7 +105,9 @@ describe('deriving what was working at teardown', () => { it('marks nothing for an idle session', () => { expect( markersAtTeardown({ - sessions: new Map([[SESSION, { journal: journal([]), child: { fence: 1 } }]]), + sessions: new Map([ + [SESSION, { journal: journal([]), child: { fence: 1, phase: 'ready' as const } }] + ]), getRecord: () => record(), childWork: () => undefined, trigger: 'quit', @@ -107,7 +121,13 @@ describe('deriving what was working at teardown', () => { expect( markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] + [ + SESSION, + { + journal: journal([turnItem('turn-1', 'completed')]), + child: { fence: 1, phase: 'ready' as const } + } + ] ]), getRecord: () => record(), childWork: () => undefined, @@ -145,7 +165,7 @@ describe('deriving what was working at teardown', () => { SESSION, { journal: journal([turnItem('turn-1', 'running'), pendingApproval()]), - child: { fence: 1 } + child: { fence: 1, phase: 'ready' as const } } ] ]), @@ -169,7 +189,13 @@ describe('deriving what was working at teardown', () => { it('marks a settled lead whose subagent was still running, anchored on its last turn', () => { const markers = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] + [ + SESSION, + { + journal: journal([turnItem('turn-1', 'completed')]), + child: { fence: 1, phase: 'ready' as const } + } + ] ]), getRecord: () => record(), childWork: () => [childRecord({ id: 'task-a', kind: 'agent', description: 'Review loop 4' })], @@ -190,7 +216,13 @@ describe('deriving what was working at teardown', () => { it('records only the live rows of the roster, bounded', () => { const [recorded] = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] + [ + SESSION, + { + journal: journal([turnItem('turn-1', 'completed')]), + child: { fence: 1, phase: 'ready' as const } + } + ] ]), getRecord: () => record(), childWork: () => [ @@ -216,7 +248,13 @@ describe('deriving what was working at teardown', () => { it('marks a settled lead whose only live work is a monitor', () => { const markers = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] + [ + SESSION, + { + journal: journal([turnItem('turn-1', 'completed')]), + child: { fence: 1, phase: 'ready' as const } + } + ] ]), getRecord: () => record(), childWork: () => [ @@ -235,7 +273,13 @@ describe('deriving what was working at teardown', () => { expect( markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] + [ + SESSION, + { + journal: journal([turnItem('turn-1', 'completed')]), + child: { fence: 1, phase: 'ready' as const } + } + ] ]), getRecord: () => record(), childWork: () => [ @@ -253,7 +297,13 @@ describe('deriving what was working at teardown', () => { it('records the identity root so an advancing Claude leaf cannot invalidate the marker', () => { const [recorded] = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] + [ + SESSION, + { + journal: journal([turnItem('turn-1', 'running')]), + child: { fence: 1, phase: 'ready' as const } + } + ] ]), getRecord: () => claudeRecord(null), childWork: () => undefined, @@ -269,7 +319,13 @@ describe('deriving what was working at teardown', () => { expect( markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] + [ + SESSION, + { + journal: journal([turnItem('turn-1', 'running')]), + child: { fence: 1, phase: 'ready' as const } + } + ] ]), getRecord: () => record({ chain: [] }), childWork: () => undefined, @@ -290,7 +346,7 @@ describe('deriving what was working at teardown', () => { SESSION, { journal: journal([], [submission('msg-1', 'pending')]), - child: { fence: 1 } + child: { fence: 1, phase: 'ready' as const } } ] ]), @@ -313,7 +369,7 @@ describe('deriving what was working at teardown', () => { SESSION, { journal: journal([turnItem('turn-0', 'completed')], [submission('msg-1', 'pending')]), - child: { fence: 1 } + child: { fence: 1, phase: 'ready' as const } } ] ]), @@ -338,7 +394,7 @@ describe('deriving what was working at teardown', () => { SESSION, { journal: journal([turnItem('turn-0', 'completed')], [queued]), - child: { fence: 1 } + child: { fence: 1, phase: 'ready' as const } } ] ]), @@ -364,14 +420,14 @@ describe('deriving what was working at teardown', () => { SESSION, { journal: journal([turnItem('turn-0', 'completed')], [handedOver, queued]), - child: { fence: 1 } + child: { fence: 1, phase: 'ready' as const } } ], [ 'session-running', { journal: journal([turnItem('turn-1', 'running')], [queued]), - child: { fence: 1 } + child: { fence: 1, phase: 'ready' as const } } ] ]), @@ -397,7 +453,7 @@ describe('deriving what was working at teardown', () => { SESSION, { journal: journal([turnItem('turn-1', 'running')], [submission('msg-1', 'accepted')]), - child: { fence: 1 } + child: { fence: 1, phase: 'ready' as const } } ] ]), 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 335d5a14686a..c0e8ee04a552 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 @@ -30,6 +30,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-ho 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' +import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' const CALLER = { callerKey: 'client-1' } @@ -39,6 +40,7 @@ let root = '' function adapter(): StructuredAgentSessionAdapter { return { acquire: async ({ fence, spawnToken }) => ({ + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -59,11 +61,11 @@ function adapter(): StructuredAgentSessionAdapter { } function createHost(store: AgentSessionRecordStore): StructuredAgentSessionHost { - const host = new StructuredAgentSessionHost({ + const host: StructuredAgentSessionHost = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: adapter(), + adapter: startsWhenPublished(adapter(), () => host), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', 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 6776a2f9e898..b36a7bcb7854 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 @@ -29,6 +29,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-ho 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' +import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' const caller = { callerKey: 'desktop' } const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 } @@ -44,39 +45,43 @@ const rewindSupport = vi.fn() function adapter(): StructuredAgentSessionAdapter { - return { - supportsCreate: (_location, agent) => agent === 'codex', - supportsLocation: () => true, - acquire: async (input) => { - acquires += 1 - sink = input.events! - return { - process: { - hostId: 'local', - pid: 4000 + acquires, - processStartTimeMs: HOST_TEST_NOW, - spawnToken: input.spawnToken - }, - acquisitionGeneration: `generation-${acquires}`, - link: { - linkId: `link-${acquires}`, - mintedAtFence: input.fence, - observedAt: HOST_TEST_NOW, - origin: acquires === 1 ? 'created' : 'resumed', - handle: codexProviderHandle(THREAD) + return startsWhenPublished( + { + supportsCreate: (_location, agent) => agent === 'codex', + supportsLocation: () => true, + acquire: async (input) => { + acquires += 1 + sink = input.events! + return { + providerChildPhase: 'starting' as const, + process: { + hostId: 'local', + pid: 4000 + acquires, + processStartTimeMs: HOST_TEST_NOW, + spawnToken: input.spawnToken + }, + acquisitionGeneration: `generation-${acquires}`, + link: { + linkId: `link-${acquires}`, + mintedAtFence: input.fence, + observedAt: HOST_TEST_NOW, + origin: acquires === 1 ? 'created' : 'resumed', + handle: codexProviderHandle(THREAD) + } } - } + }, + dispatch, + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => {}, + setOption: async () => {}, + rewindSupport, + rewind, + recoverRewind, + releaseAcquisition: async () => true, + closeSession: async () => true }, - dispatch, - cancelTurn: async () => ({ cancelled: false }), - answerPrompt: async () => {}, - setOption: async () => {}, - rewindSupport, - rewind, - recoverRewind, - releaseAcquisition: async () => true, - closeSession: async () => true - } + () => host + ) } function openHost(): StructuredAgentSessionHost { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts index 85247148f863..88474c453736 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts @@ -32,6 +32,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 { startsWhenPublished } from './structured-agent-session-instant-start.test-support' const caller = { callerKey: 'desktop' } const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 } @@ -47,39 +48,43 @@ const rewindSupport = vi.fn() function adapter(): StructuredAgentSessionAdapter { - return { - supportsCreate: (_location, agent) => agent === 'codex', - supportsLocation: () => true, - acquire: async (input) => { - acquires += 1 - sink = input.events! - return { - process: { - hostId: 'local', - pid: 4000 + acquires, - processStartTimeMs: HOST_TEST_NOW, - spawnToken: input.spawnToken - }, - acquisitionGeneration: `generation-${acquires}`, - link: { - linkId: `link-${acquires}`, - mintedAtFence: input.fence, - observedAt: HOST_TEST_NOW, - origin: acquires === 1 ? 'created' : 'resumed', - handle: codexProviderHandle(THREAD) + return startsWhenPublished( + { + supportsCreate: (_location, agent) => agent === 'codex', + supportsLocation: () => true, + acquire: async (input) => { + acquires += 1 + sink = input.events! + return { + providerChildPhase: 'starting' as const, + process: { + hostId: 'local', + pid: 4000 + acquires, + processStartTimeMs: HOST_TEST_NOW, + spawnToken: input.spawnToken + }, + acquisitionGeneration: `generation-${acquires}`, + link: { + linkId: `link-${acquires}`, + mintedAtFence: input.fence, + observedAt: HOST_TEST_NOW, + origin: acquires === 1 ? 'created' : 'resumed', + handle: codexProviderHandle(THREAD) + } } - } + }, + dispatch, + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => {}, + setOption: async () => {}, + rewindSupport, + rewind, + recoverRewind, + releaseAcquisition: async () => true, + closeSession: async () => true }, - dispatch, - cancelTurn: async () => ({ cancelled: false }), - answerPrompt: async () => {}, - setOption: async () => {}, - rewindSupport, - rewind, - recoverRewind, - releaseAcquisition: async () => true, - closeSession: async () => true - } + () => host + ) } function openHost(): StructuredAgentSessionHost { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.ts index f3664c3166f2..bba0e9321261 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.ts @@ -100,6 +100,8 @@ export async function expectRawStopNoteRewindRecovery({ hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) ).toMatchObject({ ok: true }) + // A restarted child settles the rewind in its start, which runs after the attach. + await host.collaboratorsForTests().serialize(HOST_TEST_SESSION, async () => {}) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') expect(journal.itemBody(noteId)).toEqual(unconfirmed) expect(journal.snapshot().items.find((item) => item.itemId === noteId)?.body).toEqual({ 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 1bbb02777243..f83e5ef34848 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 @@ -40,6 +40,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-ho 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' +import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' const caller = { callerKey: 'desktop' } let clock = HOST_TEST_NOW @@ -68,42 +69,46 @@ beforeEach(async () => { acquires = [] directory = await mkdtemp(join(tmpdir(), 'orca-rewind-')) store = await openTestAgentSessionRecordStore(directory) - adapter = { - supportsCreate: (_location, agent) => agent === 'codex', - supportsLocation: () => true, - acquire: async (input) => { - acquires.push(input) - sink = input.events! - return { - process: { - hostId: 'local', - pid: 4000 + acquires.length, - processStartTimeMs: HOST_TEST_NOW, - spawnToken: input.spawnToken - }, - acquisitionGeneration: `generation-${acquires.length}`, - link: { - linkId: `link-${acquires.length}`, - mintedAtFence: input.fence, - observedAt: HOST_TEST_NOW, - origin: acquires.length === 1 ? 'created' : 'resumed', - handle: codexProviderHandle(HOST_TEST_THREAD) + adapter = startsWhenPublished( + { + supportsCreate: (_location, agent) => agent === 'codex', + supportsLocation: () => true, + acquire: async (input) => { + acquires.push(input) + sink = input.events! + return { + providerChildPhase: 'starting' as const, + process: { + hostId: 'local', + pid: 4000 + acquires.length, + processStartTimeMs: HOST_TEST_NOW, + spawnToken: input.spawnToken + }, + acquisitionGeneration: `generation-${acquires.length}`, + link: { + linkId: `link-${acquires.length}`, + mintedAtFence: input.fence, + observedAt: HOST_TEST_NOW, + origin: acquires.length === 1 ? 'created' : 'resumed', + handle: codexProviderHandle(HOST_TEST_THREAD) + } } - } + }, + dispatch: vi.fn(async (): Promise => ({ + state: 'unknown', + reason: 'test' + })), + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => {}, + setOption: async () => {}, + rewindSupport: () => ({ supported: true }), + rewind, + recoverRewind, + releaseAcquisition: async () => true, + closeSession: async () => true }, - dispatch: vi.fn(async (): Promise => ({ - state: 'unknown', - reason: 'test' - })), - cancelTurn: async () => ({ cancelled: false }), - answerPrompt: async () => {}, - setOption: async () => {}, - rewindSupport: () => ({ supported: true }), - rewind, - recoverRewind, - releaseAcquisition: async () => true, - closeSession: async () => true - } + () => host + ) host = new StructuredAgentSessionHost({ agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), @@ -120,6 +125,20 @@ afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) +/** Attaches, then lets the child it started prove its start, where a rewind left in doubt is + * settled; a start that cannot settle it is stopped in a step of its own. */ +async function attachAndStart(fence: number | null) { + const attached = await host.attach(caller, hostTestAttachParams(fence)) + const { serialize } = host.collaboratorsForTests() + await serialize(HOST_TEST_SESSION, async () => {}) + await serialize(HOST_TEST_SESSION, async () => {}) + return attached +} + +function startedChild() { + return host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child ?? null +} + async function seed(acceptedSubmissions = false) { expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) const keys = ['kept', 'drop', 'tip'].map((turnId) => ({ @@ -219,7 +238,7 @@ describe('host rewind', () => { expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('provider-succeeded') replace.mockRestore() const fence = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence - expect(await host.attach(caller, hostTestAttachParams(fence))).toMatchObject({ ok: true }) + expect(await attachAndStart(fence)).toMatchObject({ ok: true }) expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) @@ -236,18 +255,14 @@ describe('host rewind', () => { expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) recoverRewind.mockRejectedValueOnce(new Error('history still unavailable')) - await expect( - host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).rejects.toThrow('history still unavailable') + // The restarted child's start cannot settle it, so that start fails and nothing is handed over. + expect( + await attachAndStart(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) + ).toMatchObject({ ok: true }) + expect(startedChild()).toBeNull() expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('prepared') expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) + await attachAndStart(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ).toMatchObject({ ok: true }) expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) expect((await host.journalSnapshot(HOST_TEST_SESSION)).items[0]?.body).toEqual( @@ -397,10 +412,7 @@ describe('host rewind', () => { }) expect(adapter.dispatch).not.toHaveBeenCalled() expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) + await attachAndStart(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ).toMatchObject({ ok: true }) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) @@ -414,10 +426,7 @@ describe('host rewind', () => { await expect(host.rewind(caller, await params(target))).rejects.toThrow('read failed') recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' }) expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) + await attachAndStart(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ).toMatchObject({ ok: true }) expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('refused') @@ -435,10 +444,7 @@ describe('host rewind', () => { expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' }) expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) + await attachAndStart(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ).toMatchObject({ ok: true }) expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('refused') @@ -720,10 +726,7 @@ describe('host rewind', () => { items: [{ identity: message('kept'), body: hostTestMessage('kept from recovery') }] }) expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) + await attachAndStart(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ).toMatchObject({ ok: true }) expect( @@ -751,10 +754,7 @@ describe('host rewind', () => { await expect(host.rewind(caller, await params(target))).rejects.toThrow('lost after revert') recoverRewind.mockResolvedValueOnce({ ok: true, items }) expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) + await attachAndStart(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ).toMatchObject({ ok: true }) expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(2) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') @@ -784,12 +784,10 @@ describe('host rewind', () => { items: missing === 'turn' ? [] : items.slice(0, 1) }) const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') - await expect( - host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).rejects.toThrow('proof-mismatch') + expect( + await attachAndStart(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) + ).toMatchObject({ ok: true }) + expect(startedChild()).toBeNull() expect(replace).not.toHaveBeenCalled() replace.mockRestore() expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.expectedEpoch).toBe(before.cursor.epoch) @@ -818,10 +816,7 @@ describe('host rewind', () => { checkpoint.mockRestore() const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) + await attachAndStart(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ).toMatchObject({ ok: true }) expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(committed) expect(replace).not.toHaveBeenCalled() 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 bd819d09f2ca..f794f02867da 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 @@ -15,15 +15,21 @@ import type { import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -import { openWithAgent } from './structured-agent-session-send-preparation' +import { + runWithReadyStructuredAgentSessionOperation, + openWithReadyStructuredAgentSessionChild, + type ReadyOperationSelection +} from './structured-agent-session-ready-operation' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' import { conversationCommandBlocked } from './structured-conversation-command-admission' -import { rewindRefusal } from './structured-rewind-refusal' +import { completedStructuredRewind } from './structured-rewind-replay' +import { rewindRefusal, rewindRefusalBehindClear } from './structured-rewind-refusal' import { renameRewindTurnOpener } from './structured-rewind-journal-body' import { persistRewindRecord, recoverStructuredRewind } from './structured-rewind-recovery' import { mergeRetainedHostLifecycleRows } from './structured-rewind-retained-host-rows' +import { rewindInDoubt } from './structured-agent-session-send-preparation' export async function rewindStructuredAgentSession( context: StructuredAgentSessionMutationContext, @@ -32,8 +38,13 @@ export async function rewindStructuredAgentSession( params: AgentSessionRewindParams ): Promise> { const { sessionId, clientOperationId } = params.envelope - const store = context.deps.store - return context.serialize(sessionId, async () => { + const { + readChildWork, + deps: { store } + } = context + const fields = { itemId: params.itemId, expectedEpoch: params.expectedEpoch } + const operation = { method: 'agentSession.rewind', fields } + const run = async (selection: ReadyOperationSelection) => { let completionReceipt: JournalOperationReceipt | undefined const result = await admitAndRunAgentSessionMutation({ store, @@ -43,26 +54,12 @@ export async function rewindStructuredAgentSession( callerKey: caller.callerKey, envelope: params.envelope, // Only the provider can do this, so an agent at rest is started first. - prepareSession: openWithAgent(context, params.envelope, () => { - const journal = context.sessions.get(sessionId)?.journal - const floor = journal?.context.floor() - if (!journal || !floor) { - return { ok: true } - } - if (journal.cursor().epoch !== params.expectedEpoch) { - return rewindRefusal('stale-epoch') - } - const target = journal.snapshot().items.find((item) => item.itemId === params.itemId) - return !target || target.sequence <= floor.sequence - ? rewindRefusal('invalid-target') - : { ok: true } - }), + prepareSession: openWithReadyStructuredAgentSessionChild(context, params.envelope, selection), journal: () => context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), now: context.now, plan: { - method: 'agentSession.rewind', - fields: { itemId: params.itemId, expectedEpoch: params.expectedEpoch }, + ...operation, recoverUnknownFromDurableState: true, settledOutcome: (rewind: AgentSessionRewindResult) => ({ status: 'succeeded' as const, @@ -78,17 +75,11 @@ export async function rewindStructuredAgentSession( if (outcome.status === 'succeeded' && outcome.rewind) { return outcome.rewind } - const prior = store.getRecord(sessionId)?.rewind - return prior?.operationId === clientOperationId && - prior.callerKey === caller.callerKey && - prior.phase === 'completed' && - prior.epoch - ? { - itemId: prior.itemId, - epoch: prior.epoch, - ...(prior.sequence !== undefined ? { sequence: prior.sequence } : {}) - } - : null + return completedStructuredRewind( + store.getRecord(sessionId)?.rewind, + caller.callerKey, + clientOperationId + ) }, run: async (ctx) => { await attachContext.runtimeState.flushEventSink(sessionId) @@ -97,13 +88,10 @@ export async function rewindStructuredAgentSession( if (!support?.supported) { return rewindRefusal(support?.reason ?? 'unsupported') } - if ( - record.rewind?.phase === 'prepared' || - record.rewind?.phase === 'provider-succeeded' - ) { + if (rewindInDoubt(record)) { return rewindRefusal('outcome-unknown') } - if (conversationCommandBlocked(ctx, record, context.readChildWork(sessionId))) { + if (conversationCommandBlocked(ctx, record, readChildWork(sessionId), 'handover')) { return rewindRefusal('busy') } const snapshot = ctx.journal.snapshot() @@ -299,5 +287,15 @@ export async function rewindStructuredAgentSession( cursor: context.sessions.get(sessionId)!.journal.cursor() } : result - }) + } + return runWithReadyStructuredAgentSessionOperation( + context, + caller.callerKey, + params.envelope, + operation, + run, + { + beforeAgent: () => rewindRefusalBehindClear(context.sessions.get(sessionId)?.journal, params) + } + ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-running-operation-order.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-running-operation-order.test.ts new file mode 100644 index 000000000000..27c25109e10d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-running-operation-order.test.ts @@ -0,0 +1,92 @@ +import { expect, it, vi } from 'vitest' +import { + CALLER, + attach, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' +import { structuredAgentSessionCompactBody } from './structured-agent-session-command-turn' +import type { StructuredAgentSessionProviderOpenResult } from './structured-agent-session-provider-open-contract' + +it.each(['goal', 'rewind'] as const)( + 'answers a %s while an earlier message is queued behind compact', + async (operation) => { + const { host, dispatch } = hostTestState() + const compact = vi.fn(async () => ({ state: 'accepted' as const, providerIdentity: null })) + host.deps.adapter.compact = compact + host.deps.adapter.changeThreadGoal = vi.fn(async () => ({ ok: true as const })) + host.deps.adapter.rewindSupport = () => ({ supported: true }) + await attach() + const body = structuredAgentSessionCompactBody() + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const queued = hostTestMessage('After compact') + expect( + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: queued }), + body: queued + }) + ).toMatchObject({ ok: true }) + const change = { kind: 'set' as const, objective: 'A goal during compact' } + const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch + const itemId = 'codex:thread-1:turn-1:1' + const result = + operation === 'goal' + ? host.changeThreadGoal(CALLER, { + envelope: envelope('agentSession.threadGoal', { change }), + change + }) + : host.rewind(CALLER, { + envelope: envelope('agentSession.rewind', { itemId, expectedEpoch }), + itemId, + expectedEpoch + }) + let settled = false + void result.then(() => { + settled = true + }) + await vi.waitFor(() => expect(settled).toBe(true)) + expect(await result).toMatchObject( + operation === 'goal' ? { ok: true } : { ok: false, refusal: { rewindReason: 'busy' } } + ) + expect(dispatch).not.toHaveBeenCalled() + expect((await host.journalSnapshot(SESSION)).submissions.at(-1)?.dispatchState).toBe('pending') + } +) + +it('releases a startup goal when earlier delivery parks behind compact', async () => { + const { host, dispatch } = hostTestState() + const opened = Promise.withResolvers() + host.deps.adapter.open = () => opened.promise + host.deps.adapter.compact = vi.fn(async () => ({ + state: 'accepted' as const, + providerIdentity: null + })) + host.deps.adapter.changeThreadGoal = vi.fn(async () => ({ ok: true as const })) + await attach() + for (const body of [structuredAgentSessionCompactBody(), hostTestMessage('After compact')]) { + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + } + const change = { kind: 'set' as const, objective: 'A goal requested during startup' } + const result = host.changeThreadGoal(CALLER, { + envelope: envelope('agentSession.threadGoal', { change }), + change + }) + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + opened.resolve({ reportedOptions: { model: '' } }) + let settled = false + void result.then(() => { + settled = true + }) + await vi.waitFor(() => expect(settled).toBe(true)) + expect(await result).toMatchObject({ ok: true }) + expect(dispatch).not.toHaveBeenCalled() +}) 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 c1edc4766772..431569f6fa97 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 @@ -27,6 +27,7 @@ import { 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' +import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' const CALLER = { callerKey: 'client-1' } @@ -49,6 +50,7 @@ beforeEach(async () => { hostErrors = [] let generation = 0 spawnChild = async ({ fence, spawnToken }) => ({ + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, acquisitionGeneration: `generation-${++generation}`, link: { @@ -79,15 +81,18 @@ beforeEach(async () => { error: (_message, fields) => hostErrors.push(fields.error) }, store, - adapter: { - acquire, - dispatch, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn: vi.fn(async () => ({ cancelled: false })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, + adapter: startsWhenPublished( + { + acquire, + dispatch, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn: vi.fn(async () => ({ cancelled: false })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + () => host + ), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, @@ -250,8 +255,7 @@ describe('a send with no live owner', () => { reason: 'startup deadline', cause: 'unexpected-exit', fence: params.envelope.expectedRuntimeFence ?? 0, - acquisitionGeneration: 'generation-1', - startupUnproven: true + acquisitionGeneration: 'generation-1' }) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') acquire.mockClear() @@ -688,7 +692,8 @@ describe('a write fenced to an owner the pane has not seen replaced', () => { expect(await submission(firstParams.envelope.clientOperationId)).toMatchObject({ dispatchState: 'rejected' }) - expect(acquire).toHaveBeenCalledOnce() + // The Stop ended the child still proving its start, so the late send started another. + expect(acquire).toHaveBeenCalledTimes(2) expect(dispatch).toHaveBeenCalledTimes(1) }) }) 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 4aac5417b656..3658b3df146d 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 @@ -18,6 +18,10 @@ import { type StructuredAgentSessionAwaitedCommandJournal } from './structured-agent-session-command-turn' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { + operationDidNotRun, + type ReadyOperationSelection +} from './structured-agent-session-ready-operation' import { AGENT_SESSION_NOT_ATTACHED, type AgentSessionMutationSessionPreparation @@ -25,12 +29,15 @@ import { import { agentSessionOperationOutcomeUnknown } from './structured-agent-session-replay-outcome' import { rewindRefusal } from './structured-rewind-refusal' import { recoverStructuredRewind } from './structured-rewind-recovery' -import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { + sameProviderChild, + structuredAgentSessionConversationFence +} from './structured-agent-session-provider-child' import { conversationCommandInFlight } from './structured-conversation-command-admission' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' -function rewindInDoubt(record: AgentSessionRecord | null | undefined): boolean { +export function rewindInDoubt(record: AgentSessionRecord | null | undefined): boolean { const phase = record?.rewind?.phase return phase === 'prepared' || phase === 'provider-succeeded' } @@ -96,28 +103,8 @@ export function openForProviderWrite( } } -/** For an operation only the provider can perform: the conversation, then its agent. */ -export function openWithAgent( - context: Pick, - envelope: AgentSessionMutationEnvelope, - beforeAgent?: () => AgentSessionMutationSessionPreparation -): (ledger: 'admit' | 'replay') => Promise { - return async (ledger) => { - const opened = await openConversationForWrite( - context.openConversation, - envelope, - context.deps.logger - ) - if (!opened.ok || ledger === 'replay') { - return opened - } - const checked = beforeAgent?.() - return checked && !checked.ok ? checked : context.ensureAgent(envelope.sessionId) - } -} - -/** The recovery an attach runs, for a child already running: no attach comes for it. A recovery - * that stays unknown leaves the record as it was. */ +/** The recovery a proven start runs, for a child already running: no start comes for it. A + * recovery that stays unknown leaves the record as it was. */ async function recoverRewindOnLiveChild( context: Pick, sessionId: string @@ -148,8 +135,9 @@ async function recoverRewindOnLiveChild( /** A rewind still in doubt once the conversation is open is one only its provider can settle — * the open settles every other — so a send settles it first: an agent at rest is started, whose - * attach recovers it, and a running one is asked as that attach would. One still in doubt refuses - * the send here, before the ledger records it, so a Retry of the same id is decided afresh. A + * proven start recovers it before the send is accepted, and a + * running one is asked as that start would. One still in doubt refuses the send here, before the + * ledger records it, so a Retry of the same id is decided afresh. A * resend of a recorded id needs only the conversation, its answer's source: it starts nothing, * and an open that fails leaves that answer unknown, never refused. `clearInFlight`: a /clear was * running when this send arrived, which refuses only its first run. `refusesInRun`: the caller's @@ -158,10 +146,11 @@ async function recoverRewindOnLiveChild( export function sendPreparation( context: Pick< StructuredAgentSessionMutationContext, - 'openConversation' | 'ensureAgent' | 'deps' | 'sessions' | 'publish' | 'now' + 'openConversation' | 'deps' | 'sessions' | 'publish' | 'now' >, envelope: AgentSessionMutationEnvelope, - arrival: { clearInFlight?: boolean; refusesInRun?: boolean } = {} + arrival: { clearInFlight?: boolean; refusesInRun?: boolean } = {}, + selection?: ReadyOperationSelection ): (ledger: 'admit' | 'replay') => Promise { return async (ledger) => { if (ledger === 'admit' && arrival.clearInFlight) { @@ -177,21 +166,27 @@ export function sendPreparation( ? opened : { ok: false, refusal: agentSessionOperationOutcomeUnknown(envelope.clientOperationId) } } + if (selection?.deadline !== undefined && Date.now() >= selection.deadline) { + return operationDidNotRun(true) + } const { sessionId } = envelope if (!opened.ok || !rewindInDoubt(context.deps.store.getRecord(sessionId))) { return opened } - const running = Boolean(context.sessions.get(sessionId)?.child) - const ensured = await context.ensureAgent(sessionId) - if (!ensured.ok) { - return ensured - } - if (running) { - await recoverRewindOnLiveChild(context, sessionId) + const child = context.sessions.get(sessionId)?.child + if ( + !selection?.child || + !child || + child.phase !== 'ready' || + child.close || + !sameProviderChild(child, selection.child) + ) { + return rewindRefusal('outcome-unknown') } + await recoverRewindOnLiveChild(context, sessionId) return !arrival.refusesInRun && rewindInDoubt(context.deps.store.getRecord(sessionId)) ? rewindRefusal('outcome-unknown') - : ensured + : opened } } 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 fb2f9beae728..ae7359e7566f 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 @@ -78,9 +78,7 @@ async function send( value: { submission: { dispatchState: 'pending', handoverRecorded: true } } }) const clientMessageId = sent.ok ? sent.value.clientMessageId : '' - await eventually(() => - expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) - ) + expect(await submission(clientMessageId)).toMatchObject({ dispatchState: 'pending' }) return clientMessageId } @@ -103,9 +101,7 @@ function proveStarted(): Promise { return host.handleAdapterEvent({ type: 'started', ...currentChild(), - reportedOptions: { model: 'sonnet' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + reportedOptions: { model: 'sonnet' } }) } @@ -115,8 +111,7 @@ function exitBeforeProof(): Promise { ...currentChild(), reason: EXIT_REASON, failure: { kind: 'providerExited', detail: { text: EXIT_REASON, audience: 'log' } }, - cause: 'unexpected-exit', - startupUnproven: true + cause: 'unexpected-exit' }) } @@ -142,6 +137,7 @@ beforeEach(async () => { resetHostTestOperationIds() generation = 0 acquire = vi.fn(async ({ fence, spawnToken }) => ({ + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -151,8 +147,7 @@ beforeEach(async () => { mintedAtFence: fence, observedAt: NOW }, - acquisitionGeneration: `generation-${++generation}`, - providerChildPhase: 'starting' as const + acquisitionGeneration: `generation-${++generation}` })) dispatch = vi.fn(async () => ({ state: 'admitted' as const })) store = await openTestAgentSessionRecordStore(root) @@ -198,7 +193,7 @@ describe('a send into a published session whose child ended before startup', () const first = await send('hello again', releasedFence) // Accepted at the lost owner's fence, held for the child delivery started. - expect(acquire).toHaveBeenCalledTimes(2) + await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') expect(dispatch).not.toHaveBeenCalled() const current = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -226,6 +221,7 @@ describe('a send into a published session whose child ended before startup', () const rowsBefore = (await journalStatuses()).length const held = await send('still not signed in', releasedFence) + await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) await exitBeforeProof() // The child never proved its start, so it was handed nothing: the message is rejected, so @@ -261,7 +257,7 @@ describe('a send into a published session whose child ended before startup', () // Retry is a fresh action: it restarts once and is handed to the new child once it proves itself. const retry = await send('signed in now') - expect(acquire).toHaveBeenCalledTimes(3) + await eventually(() => expect(acquire).toHaveBeenCalledTimes(3)) await proveStarted() await handedOver(retry) expect(dispatch).toHaveBeenCalledOnce() 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 f17ee460653e..4377cf7b4840 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 @@ -30,6 +30,7 @@ import { AgentSessionJournal } from '../agent-session-journal/journal-store' 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' +import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' const CALLER = { callerKey: 'client-1' } @@ -64,14 +65,17 @@ function accepted(): AgentSessionDispatchOutcome { } function adapter(): StructuredAgentSessionAdapter { - return { - acquire, - releaseAcquisition, - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - } + return startsWhenPublished( + { + acquire, + releaseAcquisition, + dispatch, + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + () => host + ) } function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { @@ -93,6 +97,7 @@ beforeEach(async () => { publishFault.publishCount = 0 resetHostTestOperationIds() acquire = vi.fn(async ({ fence }) => ({ + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, @@ -182,6 +187,7 @@ describe('settled attach retry', () => { throw new Error('reply lost') } return { + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, @@ -227,6 +233,7 @@ describe('settled attach retry', () => { throw new Error('reply lost') } return { + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index 9206f95fde0b..c4f08c6a71c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -1,10 +1,41 @@ +import { failedProviderChildStart } from './structured-agent-session-provider-child' +import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { structuredAgentSessionStartFailureRowIdentity } from '../../../shared/structured-agent-session-start-failure-row-key' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import type { StructuredAgentSessionStartFailureWords } from './structured-agent-session-failure-text' +import type { + StructuredAgentSessionStartFailureWords, + StructuredAgentSessionStartFailureCause +} from './structured-agent-session-failure-text' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' + +export function refusedStructuredAgentSessionStart( + record: AgentSessionRecord | null, + { + refusal, + diagnostic, + argumentProblem, + expired + }: Extract +): { startKey: null; cause: StructuredAgentSessionStartFailureCause } { + if (expired) { + return { startKey: null, cause: { failure: agentSessionFailureFact('hostStopped') } } + } + return { + startKey: null, + cause: { + refusal, + ...(diagnostic ? { diagnostic } : {}), + ...(argumentProblem ? { argumentProblem } : {}), + ...(record?.providerHandleChain.length === 0 ? { newSession: true as const } : {}) + } + } +} /** A start that failed, keyed like its row, in the words `structuredAgentSessionStartFailure` gave. */ export type StructuredAgentSessionStartFailure = StructuredAgentSessionStartFailureWords & { @@ -43,24 +74,23 @@ export function hasStructuredAgentSessionStartFailureRow( /** * A start the delivery loop needed and did not get: every queued message rejected with the same - * words, then the start's row. Writes nothing when nothing is still queued: a start whose messages - * Stop withdrew did not fail anyone. + * words, then the start's row. A failed owned open also records its cause before any message. */ export async function recordStructuredAgentSessionStartFailure( session: Pick & { fence: number }, failure: StructuredAgentSessionStartFailure ): Promise { const oldest = oldestQueuedSubmission(session) - if (!oldest) { + const startKey = failure.startKey ?? oldest?.clientMessageId + if (startKey === undefined) { return } - const startKey = failure.startKey ?? oldest.clientMessageId await session.journal.appendLifecycleBatch({ settlementId: `start-failure:${startKey}`, fence: session.fence, recovered: true, mutations: [structuredAgentSessionStartFailureRow(startKey, failure)], - rejectsQueued: { reason: failure.reason, rejection: failure.rejection } + ...(oldest ? { rejectsQueued: { reason: failure.reason, rejection: failure.rejection } } : {}) }) } @@ -78,3 +108,22 @@ export function oldestQueuedSubmission( } return oldest } + +/** A start that died while this message waited on it — a view's, say — is the message's failed + * start: settled with it, under its key, rather than started again into the same failure. */ +export function startThatFailedWhileQueued( + session: StructuredAgentSessionHostSession, + oldest: NonNullable> +): { startKey: string | null; cause: StructuredAgentSessionStartFailureCause } | null { + const ended = failedProviderChildStart(session) + if ( + !ended || + oldest.acceptedSequence === undefined || + ended.endedAt.epoch !== session.journal.cursor().epoch || + ended.endedAt.sequence < oldest.acceptedSequence + ) { + return null + } + const cause = structuredAgentSessionEndedChildFailure(ended) + return cause ? { startKey: ended.generation, cause } : null +} 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 1d2c47beb5f4..f71343d75d58 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 @@ -57,8 +57,7 @@ function exitBeforeProof(): Promise { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, acquisitionGeneration: `generation-${generation}`, reason: EXIT_REASON, - cause: 'unexpected-exit', - startupUnproven: true + cause: 'unexpected-exit' }) } @@ -120,6 +119,7 @@ beforeEach(async () => { adapter: { acquire: vi.fn(async ({ fence, spawnToken }) => { const child = { + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, @@ -133,8 +133,7 @@ beforeEach(async () => { mintedAtFence: fence, observedAt: NOW }, - acquisitionGeneration: `generation-${++generation}`, - providerChildPhase: 'starting' as const + acquisitionGeneration: `generation-${++generation}` } if (exitOnStart) { // Asked for on the lane while the start step runs, so it lands before the handover. @@ -144,8 +143,7 @@ beforeEach(async () => { fence, acquisitionGeneration: child.acquisitionGeneration, reason: EXIT_REASON, - cause: 'unexpected-exit', - startupUnproven: true + cause: 'unexpected-exit' }) } return child diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-listing.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-listing.ts new file mode 100644 index 000000000000..074e9114db52 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-listing.ts @@ -0,0 +1,58 @@ +// What a start listed reaches the account's model catalog once, with the configured default its +// start resolved: on `started`, or on Claude's settings readback that follows it. Bookkeeping: a +// failure is logged, never the start's. + +import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import { sameProviderChild } from './structured-agent-session-provider-child' + +export type StructuredAgentSessionStartListingContext = { + deps: Pick + sessions: Map + serialize: (sessionId: string, task: () => Promise) => Promise +} + +/** A provider listing, saved inside its serialized step; persistence never fails its read. */ +export function saveStructuredAgentSessionListing( + context: Pick, + sessionId: string, + listing: AgentModelCatalogLiveListing +): void { + try { + context.deps.modelCatalog?.recordLiveListing(sessionId, listing) + } catch (error) { + context.deps.logger.warn('saving the provider model list failed', { + scope: 'provider-catalog', + sessionId, + error + }) + } +} + +/** A start's late readback (Claude's settings) says once what the config resolved. Serialized + * behind the attach that published the child, which may still be indexing it. */ +export function saveStructuredAgentSessionStartReadbackListing( + context: StructuredAgentSessionStartListingContext, + reporter: { sessionId: string; acquisitionGeneration: string; fence: number }, + listing: AgentModelCatalogLiveListing +): Promise { + const { sessionId } = reporter + return context + .serialize(sessionId, async () => { + const child = context.sessions.get(sessionId)?.child + const identity = { generation: reporter.acquisitionGeneration, fence: reporter.fence } + if (child && sameProviderChild(child, identity)) { + context.deps.modelCatalog?.recordLiveListing(sessionId, listing) + } + }) + .catch((error: unknown) => { + context.deps.logger.warn('saving what a started provider resolved failed', { + scope: 'provider-started-catalog', + sessionId, + error + }) + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-readiness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-readiness.ts new file mode 100644 index 000000000000..da7f7fad76b3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-readiness.ts @@ -0,0 +1,22 @@ +export type StructuredAgentSessionStartReadiness = { + wait: (budgetMs?: number) => Promise<'ready' | 'ended' | 'timeout'> +} + +export async function waitForStructuredAgentSessionStart( + ready: Promise<'ready' | 'ended'>, + budgetMs?: number +): Promise<'ready' | 'ended' | 'timeout'> { + if (budgetMs === undefined) { + return ready + } + let timer: ReturnType | undefined + const timeout = new Promise<'timeout'>((resolve) => { + timer = setTimeout(() => resolve('timeout'), Math.max(0, budgetMs)) + timer.unref?.() + }) + try { + return await Promise.race([ready, timeout]) + } finally { + clearTimeout(timer) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts index 297b5e396cd2..14767b058d1f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts @@ -41,6 +41,4 @@ export type StructuredAgentSessionStartupAttempt = { readonly signal?: AbortSignal /** Any stdout frame or stderr line from the child: proof the start is still moving. */ readonly onOutput?: () => void - /** The conversation's option revision now; a report stamps it as its read begins. */ - readonly optionRevision: () => number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts index 631ff303cbd6..8e60ca568978 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts @@ -46,12 +46,12 @@ let acquire: Mock let dispatch: Mock let closeSession: Mock> let setOption: Mock -let readAcquisitionOptions: StructuredAgentSessionAdapter['readAcquisitionOptions'] let startupLimits: Partial | undefined const providerStarted = vi.fn() function acquisition(input: StructuredAgentSessionAcquireInput): AgentSessionAcquisition { return { + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, @@ -69,11 +69,8 @@ function acquisition(input: StructuredAgentSessionAcquireInput): AgentSessionAcq } } -/** An acquire that publishes at spawn: the child proves its start later, with `started`. */ -const publishFirst: StructuredAgentSessionAdapter['acquire'] = async (input) => ({ - ...acquisition(input), - providerChildPhase: 'starting' -}) +/** An acquire that publishes at spawn, as every one does: the child proves its start later. */ +const publishFirst: StructuredAgentSessionAdapter['acquire'] = async (input) => acquisition(input) async function startHost(): Promise { host = new StructuredAgentSessionHost({ @@ -87,8 +84,7 @@ async function startHost(): Promise { releaseAcquisition: vi.fn(async () => true), cancelTurn: vi.fn(async () => ({ cancelled: false })), answerPrompt: vi.fn(async () => undefined), - setOption, - ...(readAcquisitionOptions ? { readAcquisitionOptions } : {}) + setOption }, modelCatalog: { read: vi.fn(async () => ({ origin: 'unknown' as const })), @@ -135,7 +131,6 @@ beforeEach(async () => { })) closeSession = vi.fn(async () => true) setOption = vi.fn(async () => undefined) - readAcquisitionOptions = undefined store = await openTestAgentSessionRecordStore(root) await startHost() expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) @@ -182,41 +177,16 @@ async function submission(id: string): Promise { const id = await accept(text) @@ -319,8 +289,7 @@ describe('a start that ends before it proves itself', () => { fence: store.getRecord(SESSION)!.lease.runtimeFence, acquisitionGeneration: `generation-${acquire.mock.calls.length}`, reason: 'exited with code 1', - cause: 'unexpected-exit', - startupUnproven: true + cause: 'unexpected-exit' }) await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) @@ -380,7 +349,7 @@ describe('a start that ends before it proves itself', () => { await restartWith(async (input) => { await input.onSpawned?.(acquisition(input).process) chatter.push(setInterval(() => input.onOutput?.(), 10)) - return { ...acquisition(input), providerChildPhase: 'starting' } + return acquisition(input) }) try { const id = await heldBehindStart('hello') @@ -416,22 +385,7 @@ describe('a start that ends before it proves itself', () => { await eventually(() => expect(startupAttemptOpen()).toBe(false)) }) - it('ends a ready child’s option read that never answers, and fails what it held', async () => { - startupLimits = { silenceMs: 50 } - readAcquisitionOptions = () => new Promise(() => {}) - await restartWith(async (input) => { - await input.onSpawned?.(acquisition(input).process) - return acquisition(input) - }) - const id = await accept('hello') - - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected'), 50) - expect(await submission(id)).toMatchObject(HOST_STOPPED) - expect(dispatch).not.toHaveBeenCalled() - await eventually(() => expect(startupAttemptOpen()).toBe(false)) - }) - - it('ends a ready child inside its start when the chat closes before its options are read', async () => { + it('ends a start whose acquire is still running when the chat closes', async () => { let land = (): void => {} const landed = new Promise((resolve) => { land = resolve @@ -468,149 +422,38 @@ describe('a start that ends before it proves itself', () => { }) describe('what a started child reports', () => { - it('is persisted only after what was held is handed over', async () => { - await restartWith(publishFirst) - await heldBehindStart('hello') - const order: string[] = [] - dispatch.mockImplementationOnce(async (input) => { - order.push('dispatch') - return { - state: 'accepted' as const, - providerIdentity: { - provider: 'codex' as const, - threadId: THREAD, - turnId: `turn-${input.clientMessageId}`, - ordinal: 1 - } - } - }) - const replace = store.replaceSessionOptions.bind(store) - vi.spyOn(store, 'replaceSessionOptions').mockImplementation(async (args) => { - order.push('persist') - return replace(args) - }) - - await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'reported' } }) - - await eventually(() => expect(order).toEqual(['dispatch', 'persist'])) - expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'reported' }) - }) - - it('never overwrites a pick made after the child reported', async () => { - await restartWith(publishFirst) - await heldBehindStart('hello') - const report = { ...startedEvent(), reportedOptions: { model: 'reported' } } - dispatch.mockImplementationOnce(async () => { - // A pick lands while the first handover is still running. - void pick('picked') - return { state: 'admitted' as const } - }) - - await host.handleAdapterEvent(report) - await eventually(() => expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' })) - await vi.advanceTimersByTimeAsync(50) - await host.flushStreamedEvents(SESSION) - - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' }) - }) - - it('never lets a settings read made before a pick undo it, even queued behind the pick', async () => { + it('keeps provider reports out of the saved next-message choice', async () => { await restartWith(publishFirst) const id = await heldBehindStart('hello') - await host.handleAdapterEvent(startedEvent()) + await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'reported' } }) await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - await eventually(() => expect(store.getRecord(SESSION)?.options).toEqual({ model: 'default' })) - // The settings read starts, then the user picks while it is out. - const readAt = optionRevisionNow() - let landPick = (): void => {} - setOption.mockImplementationOnce( - () => - new Promise((resolve) => { - landPick = () => resolve(undefined) - }) - ) - const picked = pick('picked') - await eventually(() => expect(setOption).toHaveBeenCalled()) - - // The read answers with what the child ran before the pick, while the pick is still in flight. - const reported = host.handleAdapterEvent({ - ...startedEvent(), - type: 'options-reported', - reportedOptions: { model: 'default' }, - optionRevision: readAt - }) - landPick() - expect(await picked).toMatchObject({ ok: true }) - await reported - await host.flushStreamedEvents(SESSION) - - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' }) - }) - - it('never lands the start’s report after a newer settings report', async () => { - await restartWith(publishFirst) - await heldBehindStart('hello') - let landHandover = (): void => {} - dispatch.mockImplementationOnce( - () => - new Promise((resolve) => { - landHandover = () => resolve({ state: 'admitted' as const }) - }) - ) - // The start's report waits on the handover; the settings report that follows it does not. - await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'at-start' } }) - await eventually(() => expect(dispatch).toHaveBeenCalled()) - const reported = host.handleAdapterEvent({ + await host.handleAdapterEvent({ ...startedEvent(), type: 'options-reported', reportedOptions: { model: 'from-settings' } }) - - landHandover() - await reported - await vi.advanceTimersByTimeAsync(50) await host.flushStreamedEvents(SESSION) - - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'from-settings' }) + expect(store.getRecord(SESSION)?.options).toBeUndefined() }) - it('keeps a settings read stamped before the host took the start’s report', async () => { - await restartWith(publishFirst) - await heldBehindStart('hello') - // Both reads are stamped before the host takes either report, as a delivery that lags would. - const started = { ...startedEvent(), reportedOptions: { model: 'at-start' } } - const settings = { - ...started, - type: 'options-reported' as const, - reportedOptions: { model: 'from-settings' } - } - - await host.handleAdapterEvent(started) - await host.handleAdapterEvent(settings) - await vi.advanceTimersByTimeAsync(50) - await host.flushStreamedEvents(SESSION) - - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'from-settings' }) - }) - - it('takes a report read after a pick that failed', async () => { + it('preserves a later accepted pick when an older child reports options', async () => { await restartWith(publishFirst) const id = await heldBehindStart('hello') - await host.handleAdapterEvent(startedEvent()) + await store.replaceSessionOptions({ + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + options: { model: 'picked' }, + now: NOW + }) + await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'reported' } }) await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - await eventually(() => expect(store.getRecord(SESSION)?.options).toEqual({ model: 'default' })) - setOption.mockRejectedValueOnce(new Error('the provider refused the model')) - await expect(pick('refused')).rejects.toThrow('the provider refused the model') - await host.handleAdapterEvent({ ...startedEvent(), type: 'options-reported', reportedOptions: { model: 'from-settings' } }) - - await eventually(() => - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'from-settings' }) - ) + await host.flushStreamedEvents(SESSION) + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' }) }) }) @@ -632,9 +475,6 @@ describe('the attempt an acquire runs under', () => { expect(input.onOutput).toEqual(expect.any(Function)) expect(input.attemptId).toEqual(expect.any(String)) expect(input.signal).toBeInstanceOf(AbortSignal) - expect(input.optionRevision!()).toBe( - host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) - ) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts index 64c681c9f9ed..13bd46a60d35 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts @@ -1,3 +1,4 @@ +import type { StructuredAgentSessionProviderOpenResult } from './structured-agent-session-provider-open-contract' // Every agent this build registers starts under the host's one startup attempt and its hold on // delivery. Iterates the runtime's real registration list, so an agent registered later is covered // without being named here. @@ -64,6 +65,7 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ store: AgentSessionRecordStore acquire: Mock dispatch: Mock + opened: PromiseWithResolvers }> { root = await mkdtemp(join(tmpdir(), 'orca-startup-registrations-')) resetHostTestOperationIds() @@ -90,7 +92,9 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ const dispatch: Mock = vi.fn(async () => ({ state: 'admitted' as const })) + const opened = Promise.withResolvers() const adapter: StructuredAgentSessionAdapter = { + open: () => opened.promise, acquire, dispatch, closeSession: vi.fn(async () => true), @@ -114,7 +118,7 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, now: () => NOW }) - return { host, store, acquire, dispatch } + return { host, store, acquire, dispatch, opened } } describe.each( @@ -123,7 +127,7 @@ describe.each( ) )('%s starts under the host attempt', (agent, definition) => { it('acquires under a host-minted attempt and is handed nothing until it proves its start', async () => { - const { host, store, acquire, dispatch } = await hostFor(definition) + const { host, store, acquire, dispatch, opened } = await hostFor(definition) try { const attached = await host.attach( CALLER, @@ -160,20 +164,12 @@ describe.each( }) expect(sent).toMatchObject({ ok: true }) await vi.waitFor(() => - expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe( - false - ) + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(true) ) expect(dispatch).not.toHaveBeenCalled() - await host.handleAdapterEvent({ - type: 'started', - sessionId: SESSION, - fence: record.lease.runtimeFence, - acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'default' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + opened.resolve({ + reportedOptions: { model: 'default' } }) await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts index ddf04b4b0e73..841b35825589 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts @@ -34,8 +34,7 @@ function mint() { agent: 'codex', providerHandle: null }, - spawnToken: 'spawn-1', - optionRevision: () => 0 + spawnToken: 'spawn-1' }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts index b704ec9eee30..83b9c7a2a172 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts @@ -11,6 +11,11 @@ // attempt ends nothing. import { randomUUID } from 'node:crypto' +import { + waitForStructuredAgentSessionStart, + type StructuredAgentSessionStartReadiness +} from './structured-agent-session-start-readiness' +export type { StructuredAgentSessionStartReadiness } from './structured-agent-session-start-readiness' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' @@ -30,7 +35,6 @@ export function mintStructuredAgentSessionStartupAttempt(input: { permissionMode?: AgentChatPermissionMode events?: StructuredAgentSessionEventSink signal?: AbortSignal - optionRevision: () => number }): StructuredAgentSessionStartupAttempt { const { record } = input return { @@ -46,8 +50,7 @@ export function mintStructuredAgentSessionStartupAttempt(input: { ...(record.options ? { options: record.options } : {}), ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}), ...(input.events ? { events: input.events } : {}), - ...(input.signal ? { signal: input.signal } : {}), - optionRevision: input.optionRevision + ...(input.signal ? { signal: input.signal } : {}) } } @@ -97,6 +100,7 @@ type Tracked = { /** The limit's measure, held until an acquire it aborted settles: one that is ready anyway * reports that instead. */ expiry: StructuredAgentSessionStartupSettled | null + readiness: PromiseWithResolvers<'ready' | 'ended'> } /** Each session's open attempt and its startup clock. A session starts one child at a time, so a @@ -134,7 +138,8 @@ export class StructuredAgentSessionStartupAttempts { lastOutputAt: 0, timer: null, expired: false, - expiry: null + expiry: null, + readiness: Promise.withResolvers() } this.open.set(sessionId, tracked) const current = (): boolean => this.open.get(sessionId) === tracked && !this.disposed @@ -158,12 +163,26 @@ export class StructuredAgentSessionStartupAttempts { } } - /** The acquire returned: a `starting` child stays on the clock until it proves its start, and + /** Capture the attempt while the selected child still holds the session's queue. */ + readiness( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity + ): StructuredAgentSessionStartReadiness | null { + const tracked = this.open.get(sessionId) + if (!tracked?.child || !sameProviderChild(tracked.child, child)) { + return null + } + return { + wait: (budgetMs) => waitForStructuredAgentSessionStart(tracked.readiness.promise, budgetMs) + } + } + + /** The acquire returned: its `starting` child stays on the clock until it proves its start, and * one published after the limit passed is expired now. */ published( sessionId: string, attemptId: string, - child: StructuredAgentSessionProviderChildIdentity & { phase: 'starting' | 'ready' } + child: StructuredAgentSessionProviderChildIdentity & { phase?: 'starting' | 'ready' } ): void { const tracked = this.open.get(sessionId) if (tracked?.attempt.attemptId !== attemptId) { @@ -184,6 +203,18 @@ export class StructuredAgentSessionStartupAttempts { } } + /** Whether `child`'s start is still on its clock: false once its limit passed, so a `started` + * that lost the race to the limit is never accepted. */ + onClock(sessionId: string, child: StructuredAgentSessionProviderChildIdentity): boolean { + const tracked = this.open.get(sessionId) + return ( + !this.disposed && + !!tracked?.child && + !tracked.expired && + sameProviderChild(tracked.child, child) + ) + } + /** The child proved its start: its attempt is over. A stale child's proof ends nothing. */ ready(sessionId: string, child: StructuredAgentSessionProviderChildIdentity): void { const tracked = this.open.get(sessionId) @@ -217,6 +248,7 @@ export class StructuredAgentSessionStartupAttempts { dispose(): void { this.disposed = true for (const tracked of this.open.values()) { + tracked.readiness.resolve('ended') if (tracked.timer) { clearTimeout(tracked.timer) } @@ -281,6 +313,7 @@ export class StructuredAgentSessionStartupAttempts { } private settle(sessionId: string, tracked: Tracked, outcome: 'ready' | 'ended'): void { + tracked.readiness.resolve(outcome) this.report( tracked.expired && outcome === 'ended' ? tracked.expiry : this.measure(tracked, outcome) ) @@ -313,6 +346,7 @@ export class StructuredAgentSessionStartupAttempts { private end(sessionId: string): void { const tracked = this.open.get(sessionId) + tracked?.readiness.resolve('ended') if (tracked?.timer) { clearTimeout(tracked.timer) } 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 fed25d7dc9b0..82361467d704 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 @@ -75,13 +75,15 @@ const ended = { describe('a provider that ends before it finished starting', () => { it('tells the user why, even with no response in progress', async () => { - const session = startedSession() + const session = { + ...startedSession(), + child: { generation: GENERATION, fence: 7, phase: 'starting' as const } + } await settleStructuredAgentSessionChildExit(contextFor(session), { ...ended, // The adapter typed the start's own failure; the host keeps it rather than reword it. - failure: { kind: 'notSignedIn' }, - startupUnproven: true + failure: { kind: 'notSignedIn' } }) expect(session.journal.appendLifecycleBatch).toHaveBeenCalledWith( @@ -111,7 +113,7 @@ describe('a provider that ends before it finished starting', () => { expect(session.journal.appendLifecycleBatch).not.toHaveBeenCalled() }) - it("reads a start that failed off the host's own phase when the provider omits the flag", async () => { + it("reads a start that failed off the host's own phase", async () => { const session = { ...startedSession(), child: { generation: GENERATION, fence: 7, phase: 'starting' as const } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index d0b286b24b91..3dd914f647fa 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 @@ -43,6 +43,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- 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' +import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'Codex stopped while this response was in progress. You can continue in this conversation.' @@ -63,15 +64,18 @@ let hostErrors: unknown[] let statusSink: StructuredAgentSessionStatusSink let clock: number function adapter(): StructuredAgentSessionAdapter { - return { - acquire, - closeSession, - releaseAcquisition: vi.fn(async () => true), - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: false })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - } + return startsWhenPublished( + { + acquire, + closeSession, + releaseAcquisition: vi.fn(async () => true), + dispatch, + cancelTurn: vi.fn(async () => ({ cancelled: false })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + () => host + ) } function openHost( @@ -220,6 +224,7 @@ beforeEach(async () => { acquire = vi.fn(async ({ fence, spawnToken, events }) => { sink = events ?? null return { + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, acquisitionGeneration: `generation-${++generation}`, link: { 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 4af804263aec..0445b9bf6eef 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 @@ -1,7 +1,11 @@ // `agentSession.threadGoal`: change the provider thread's goal through the same // admission, ledger and journal path every other session mutation takes. -import { refuse, type AgentSessionRefusalReason } from '../../../shared/agent-session-wire-refusals' +import { + refuse, + type AgentSessionRefusalReason, + type AgentSessionWireRefusal +} from '../../../shared/agent-session-wire-refusals' import type { AgentJournalItemIdentity, AgentJournalThreadGoal @@ -11,16 +15,32 @@ import type { AgentSessionThreadGoalChange, AgentSessionThreadGoalResult } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentRegistry } from './structured-agent-registry' import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' function refused( reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, message: string -): TurnOutcome { +): { ok: false; refusal: AgentSessionWireRefusal } { return { ok: false, refusal: refuse('agent_session_operation_invalid', { reason }, message) } } +const goalsUnsupported = () => + refused('goalsUnsupported', 'Goals are unavailable for this chat session.') + +/** The refusal for an agent with no goals; null when it has them. Needs no running agent. */ +export function refuseThreadGoalUnsupported( + adapter: Pick, + agents: Pick, + agent: string | undefined +): { ok: false; refusal: AgentSessionWireRefusal } | null { + return adapter.changeThreadGoal && agent && agents.capabilities(agent)?.threadGoal + ? null + : goalsUnsupported() +} + /** Keyed by the operation, so a replayed set upserts its one objective row. */ function objectiveIdentity(clientOperationId: string): AgentJournalItemIdentity { return { provider: 'orca', clientMessageId: `thread-goal:${clientOperationId}` } @@ -47,8 +67,11 @@ export async function performThreadGoalChange( ctx: AgentSessionTurnContext, input: { clientOperationId: string; change: AgentSessionThreadGoalChange } ): Promise> { - if (!ctx.adapter.changeThreadGoal || !ctx.agents.capabilities(ctx.agent)?.threadGoal) { - return refused('goalsUnsupported', 'Goals are unavailable for this chat session.') + if ( + !ctx.adapter.changeThreadGoal || + refuseThreadGoalUnsupported(ctx.adapter, ctx.agents, ctx.agent) + ) { + return goalsUnsupported() } const { change } = input const identity = objectiveIdentity(input.clientOperationId) 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 9e2863d8f90d..8c79c9290235 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 @@ -160,7 +160,7 @@ export async function performSend( if (existing) { return { ok: true, - value: { clientMessageId: input.clientMessageId, submission: existing } + value: { clientMessageId: input.clientMessageId, submission: { ...existing } } } } try { @@ -293,5 +293,6 @@ function requireSubmission( if (!submission) { throw new Error('agent_session_submission_lost') } - return submission + // The answer as of this step: the journal settles its own row in place afterwards. + return { ...submission } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts index e4de581bf067..bb2610096120 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts @@ -35,8 +35,6 @@ beforeEach(async () => { rig = await createQueuedMessageTestRig({ restartable: true, starting: true, - startUnanswered: true, - stopEndsSession: true, recoveryCapsule: true }) }) @@ -83,7 +81,6 @@ describe('a message held behind a start that never answered, then the chat ends' rig = await createQueuedMessageTestRig({ restartable: true, starting: true, - stopEndsSession: true, recoveryCapsule: true }) await rig.host.close(SESSION, 'evict') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index aae177bbd650..fe724185de34 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -164,7 +164,7 @@ function liveTasks( type WorkingCandidateSession = { journal: AgentSessionJournal /** Only this host generation's own child counts. A restored-for-reading journal has none. */ - child: { fence: number } | null + child: { fence: number; phase?: 'starting' | 'ready' } | null } /** The offer one session is owed, taken right before teardown stops its provider child; null when @@ -190,7 +190,8 @@ export function structuredAgentSessionWorkingAtStop(input: { const current = agentSessionCurrentContextRows(snapshot.items, snapshot.submissions) // A queued message reached no agent, and one handed to a start that never answered ran nowhere: // neither is work to resume. The next open or the exit keeps it as unsent. - const unanswered = input.startAnswered?.(sessionId) === false + const unanswered = + session.child.phase === 'starting' || input.startAnswered?.(sessionId) === false const handedOver = current.submissions.filter( (submission) => !isQueuedAgentJournalSubmission(submission) && 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 a2597bfd0a4d..7191ada60a86 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 @@ -105,7 +105,20 @@ beforeEach(async () => { location.executionHostId === 'local' && location.wslDistro === null, acquire: vi.fn(async (input) => { acquisitions++ + const acquisitionGeneration = `acquisition-${acquisitions}` + const starter = host + // The handshake answers at once, reporting what the child runs with. + void starter.handleAdapterEvent({ + type: 'started', + sessionId: input.identity.sessionId, + fence: input.fence, + acquisitionGeneration, + reportedOptions: { model: '' } + // Never admitted, so the empty report persists nothing. + }) return { + providerChildPhase: 'starting' as const, + acquisitionGeneration, process: { hostId: 'local', pid: 4000 + acquisitions, 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 33452e2106c1..64dc2cd0f0f1 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 @@ -10,6 +10,10 @@ import { admitAndRunAgentSessionMutation } from './structured-agent-session-muta import { agentSessionOperationKey } from '../../../shared/agent-session-operation-ledger' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { sendPreparation } from './structured-agent-session-send-preparation' +import { + runAfterRewindStart, + type ReadyOperationSelection +} from './structured-agent-session-ready-operation' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { conversationCommandBlocked } from './structured-conversation-command-admission' import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -49,6 +53,21 @@ export function runStructuredConversationCommand( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, params: ConversationCommandParams +): Promise> { + return runAfterRewindStart( + context, + caller.callerKey, + params.envelope, + { method: 'agentSession.conversationCommand', fields: { command: params.command } }, + (selection) => clearUnderSerialize(context, caller, params, selection) + ) +} + +async function clearUnderSerialize( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: ConversationCommandParams, + selection: ReadyOperationSelection ): Promise> { const { envelope, command } = params const { sessionId, clientOperationId } = envelope @@ -59,94 +78,92 @@ export function runStructuredConversationCommand( ? record : null } - return context.serialize(sessionId, async () => { - let clear: AgentSessionConversationClear | null = null - const result = await admitAndRunAgentSessionMutation({ - store, - adapter: context.deps.adapter, - agents: context.deps.agents, - logger: context.deps.logger, - callerKey: caller.callerKey, - envelope, - // Starts the agent only to settle a rewind in doubt, as a send does; a /clear itself starts nothing. - prepareSession: sendPreparation(context, envelope, { refusesInRun: true }), - journal: () => context.sessions.get(sessionId)?.journal, - publish: (journal) => context.publish(sessionId, journal), - now: context.now, - plan: { - method: 'agentSession.conversationCommand', - fields: { command }, - // Written to the conversation, not the agent, so whoever owns the agent does not matter. - conversationWrite: true, - recoverUnknownFromDurableState: true, - settlesWithWrite: true, - successReceipt: () => - store.conversationReceipts.clear( - () => { - if (!clear) { - throw new Error('agent_session_clear_not_completed') - } - return clear - }, - { callerKey: caller.callerKey, operationId: clientOperationId } - ), - replay: (_ctx, outcome) => { - if (outcome.status === 'succeeded' && outcome.conversationCommand) { - return outcome.conversationCommand - } - const prior = matching() - return prior?.phase === 'committed' ? prior : null - }, - // The commit is the only write, so a clear with no committed answer changed nothing. - rerunWhenReplayMissing: () => true, - run: async (ctx) => { - const record = store.getRecord(sessionId)! - const blocked = conversationCommandBlocked( - ctx, - record, - context.readChildWork(sessionId), - context.sessions.get(sessionId)?.child ? undefined : 'at-rest' - ) - if (blocked) { - return { ok: false, refusal: blocked } - } - await context.stopAgent(sessionId, { cause: 'context-clear' }) - const stopped = store.getRecord(sessionId)! - const fence = stopped.lease.runtimeFence - const rechecked = conversationCommandBlocked( - { ...ctx, fence }, - stopped, - context.readChildWork(sessionId), - 'at-rest' - ) - if (rechecked) { - return { ok: false, refusal: rechecked } - } - const completed: AgentSessionConversationClear['command'] = { - command: 'clear', - runtimeFence: fence, - operationId: clientOperationId, - callerKey: caller.callerKey, - phase: 'committed', - state: 'completed' - } - clear = { sessionId, fence, command: completed, now: context.now() } - if (!ctx.operationReceipt) { - throw new Error('agent_session_clear_receipt_missing') - } - await ctx.journal.context.clear( - providerContextBoundaryForClear(clear), - ctx.operationReceipt, - agentSessionOperationKey(caller.callerKey, clientOperationId) - ) - return { ok: true, value: completed } + let clear: AgentSessionConversationClear | null = null + const result = await admitAndRunAgentSessionMutation({ + store, + adapter: context.deps.adapter, + agents: context.deps.agents, + logger: context.deps.logger, + callerKey: caller.callerKey, + envelope, + // Starts the agent only to settle a rewind in doubt, as a send does; a /clear itself starts nothing. + prepareSession: sendPreparation(context, envelope, { refusesInRun: true }, selection), + journal: () => context.sessions.get(sessionId)?.journal, + publish: (journal) => context.publish(sessionId, journal), + now: context.now, + plan: { + method: 'agentSession.conversationCommand', + fields: { command }, + // Written to the conversation, not the agent, so whoever owns the agent does not matter. + conversationWrite: true, + recoverUnknownFromDurableState: true, + settlesWithWrite: true, + successReceipt: () => + store.conversationReceipts.clear( + () => { + if (!clear) { + throw new Error('agent_session_clear_not_completed') + } + return clear + }, + { callerKey: caller.callerKey, operationId: clientOperationId } + ), + replay: (_ctx, outcome) => { + if (outcome.status === 'succeeded' && outcome.conversationCommand) { + return outcome.conversationCommand + } + const prior = matching() + return prior?.phase === 'committed' ? prior : null + }, + // The commit is the only write, so a clear with no committed answer changed nothing. + rerunWhenReplayMissing: () => true, + run: async (ctx) => { + const record = store.getRecord(sessionId)! + const blocked = conversationCommandBlocked( + ctx, + record, + context.readChildWork(sessionId), + context.sessions.get(sessionId)?.child ? undefined : 'at-rest' + ) + if (blocked) { + return { ok: false, refusal: blocked } + } + await context.stopAgent(sessionId, { cause: 'context-clear' }) + const stopped = store.getRecord(sessionId)! + const fence = stopped.lease.runtimeFence + const rechecked = conversationCommandBlocked( + { ...ctx, fence }, + stopped, + context.readChildWork(sessionId), + 'at-rest' + ) + if (rechecked) { + return { ok: false, refusal: rechecked } + } + const completed: AgentSessionConversationClear['command'] = { + command: 'clear', + runtimeFence: fence, + operationId: clientOperationId, + callerKey: caller.callerKey, + phase: 'committed', + state: 'completed' + } + clear = { sessionId, fence, command: completed, now: context.now() } + if (!ctx.operationReceipt) { + throw new Error('agent_session_clear_receipt_missing') } + await ctx.journal.context.clear( + providerContextBoundaryForClear(clear), + ctx.operationReceipt, + agentSessionOperationKey(caller.callerKey, clientOperationId) + ) + return { ok: true, value: completed } } - }) - return result.ok && - 'runtimeFence' in result.value && - typeof result.value.runtimeFence === 'number' - ? { ...result, fence: result.value.runtimeFence } - : result + } }) + return result.ok && + 'runtimeFence' in result.value && + typeof result.value.runtimeFence === 'number' + ? { ...result, fence: result.value.runtimeFence } + : result } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts index 519a22bd3b5b..9ae63858ca30 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts @@ -13,7 +13,7 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-qu import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' import type { StructuredAgentSessionHost } from './structured-agent-session-host' import { - mutateStructuredAgentSession, + runStructuredAgentSessionMutation, type StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' @@ -26,6 +26,10 @@ import { structuredAgentSessionFailureWordsContext, structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' +import { + runAfterRewindStart, + type ReadyOperationSelection +} from './structured-agent-session-ready-operation' import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from './structured-agent-session-send-settlement' import { conversationCommandFailure, @@ -146,7 +150,20 @@ function acceptStructuredConversationCommand( ...(params.delivery ? { delivery: params.delivery } : {}), priorRecord }) - return mutateStructuredAgentSession( + return runAfterRewindStart(context, caller.callerKey, params.envelope, plan, (selection) => + acceptUnderSerialize(context, caller, params, plan, arrival, selection) + ) +} + +function acceptUnderSerialize( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: ConversationCommandParams, + plan: ReturnType, + arrival: { clearInFlight?: boolean }, + selection: ReadyOperationSelection +): Promise> { + return runStructuredAgentSessionMutation( context, caller, params.envelope, @@ -193,6 +210,6 @@ function acceptStructuredConversationCommand( return plan.run(ctx) } }, - sendPreparation(context, params.envelope, arrival) + sendPreparation(context, params.envelope, arrival, selection) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts index b0320ffdcbcf..23a51acec32c 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts @@ -1,3 +1,5 @@ +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import { mergeRetainedHostLifecycleRows, retainedRowReplacement @@ -5,6 +7,7 @@ import { import { isDeepStrictEqual } from 'node:util' import { agentJournalItemKey, + agentJournalSubmissionKey, parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind' @@ -78,8 +81,10 @@ export async function recoverStructuredRewind( rewind: AgentSessionRewindRecord, fence: number, cursor: AgentJournalCursor - ) => JournalOperationReceipt + ) => JournalOperationReceipt, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() const { store } = deps let rewind = store.getRecord(sessionId)?.rewind if (rewind?.phase !== 'provider-succeeded' && rewind?.phase !== 'prepared') { @@ -91,11 +96,16 @@ export async function recoverStructuredRewind( return } if (target?.provider === 'codex' && !rewind.hydrationVerified) { - const recovered = await adapter?.recoverRewind?.({ + const read = adapter?.recoverRewind?.({ sessionId, fence, - beforeTurnId: target.turnId + beforeTurnId: target.turnId, + ...(signal ? { signal } : {}) }) + const recovered = signal + ? await waitForPromiseWithSignal(Promise.resolve(read), signal) + : await read + signal?.throwIfAborted() if (!recovered?.ok) { // The target is still in the provider's history, and the journal is replaced only once the // revert is proven, so both still hold it even when the provider acknowledged the revert. @@ -176,13 +186,22 @@ export async function recoverStructuredRewind( } // A crash after the journal transaction must settle its existing epoch, not replace it twice. const alreadyReplaced = journal.cursor().epoch !== rewind.expectedEpoch + const queued = new Set( + journal + .submissions() + .filter(isQueuedAgentJournalSubmission) + .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) + ) if ( alreadyReplaced && !isDeepStrictEqual( - journal.snapshot().items.map(({ itemId }) => ({ - itemId, - body: journal.itemBody(itemId) - })), + journal + .snapshot() + .items.filter(({ itemId }) => !queued.has(itemId)) + .map(({ itemId }) => ({ + itemId, + body: journal.itemBody(itemId) + })), replacement.map(({ identity, body }) => ({ itemId: agentJournalItemKey(identity), body })) ) ) { diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts b/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts index af309b13f405..aa37ca23fb4a 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts @@ -1,8 +1,10 @@ import { AGENT_SESSION_REWIND_REASONS, + type AgentSessionRewindParams, type AgentSessionRewindReason } from '../../../shared/agent-session-rewind' import { refuse, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' export function rewindRefusal(reason: AgentSessionRewindReason): { ok: false @@ -27,3 +29,20 @@ export function rewindRefusal(reason: AgentSessionRewindReason): { ) } } + +/** A rewind to a row a /clear left behind, or against an epoch since moved, is refused before any + * agent starts for it; null when the journal has no clear to check against. */ +export function rewindRefusalBehindClear( + journal: Pick | undefined, + target: Pick +): ReturnType | null { + const floor = journal?.context.floor() + if (!journal || !floor) { + return null + } + if (journal.cursor().epoch !== target.expectedEpoch) { + return rewindRefusal('stale-epoch') + } + const item = journal.snapshot().items.find((entry) => entry.itemId === target.itemId) + return !item || item.sequence <= floor.sequence ? rewindRefusal('invalid-target') : null +} diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-replay.ts b/src/main/native-chat/agent-session-wire/structured-rewind-replay.ts new file mode 100644 index 000000000000..e6565290e6b7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-rewind-replay.ts @@ -0,0 +1,21 @@ +import type { + AgentSessionRewindRecord, + AgentSessionRewindResult +} from '../../../shared/agent-session-rewind' + +export function completedStructuredRewind( + prior: AgentSessionRewindRecord | undefined, + callerKey: string, + operationId: string +): AgentSessionRewindResult | null { + return prior?.operationId === operationId && + prior.callerKey === callerKey && + prior.phase === 'completed' && + prior.epoch + ? { + itemId: prior.itemId, + epoch: prior.epoch, + ...(prior.sequence !== undefined ? { sequence: prior.sequence } : {}) + } + : null +} diff --git a/src/main/native-chat/native-chat-inline-visuals-setting.test.ts b/src/main/native-chat/native-chat-inline-visuals-setting.test.ts index 9e1b0d05f642..eaaed95f5420 100644 --- a/src/main/native-chat/native-chat-inline-visuals-setting.test.ts +++ b/src/main/native-chat/native-chat-inline-visuals-setting.test.ts @@ -9,6 +9,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { CLAUDE_PLUGIN_DIR_FLAG, type ClaudeCliFlag } from '../claude/claude-cli-flag-support' import { + acquireReadyCodexForTest, adapterFor, fakeCodex, identityFor @@ -99,8 +100,9 @@ describe('inline visuals launch preference', () => { prepareVisuals }) const adapter = adapterFor(codex, {}, [], { resolveLaunch }) + // The thread opens after the acquire returns; each check below reads its `thread/start`. const start = (sessionId: string) => - adapter.acquire({ + acquireReadyCodexForTest(adapter, { identity: { ...identityFor(sessionId), hostId: LOCAL_EXECUTION_HOST_ID, diff --git a/src/main/runtime/agent-session-acquisition-failure-settlement.ts b/src/main/runtime/agent-session-acquisition-failure-settlement.ts index 68db039d6ce1..ec400dd5c522 100644 --- a/src/main/runtime/agent-session-acquisition-failure-settlement.ts +++ b/src/main/runtime/agent-session-acquisition-failure-settlement.ts @@ -81,8 +81,7 @@ export function settleFailedAgentSessionPostAcquisitionAttachment( record.lease.claimStatus !== 'live' || record.lease.handoffStage !== null || record.lease.ownerProcess?.spawnToken !== args.spawnToken || - record.lease.reservedSpawnToken !== args.spawnToken || - record.lease.provenHandleLinkId === null + record.lease.reservedSpawnToken !== args.spawnToken ) { throw new Error('agent_session_ownership_unknown') } diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 950e05a8e78e..8a1dec6f13b2 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -125,13 +125,14 @@ export function commitAgentSessionProcessIdentity( } /** - * The new runtime proved it resumed the expected provider handle. Only now does the session have - * a writer. + * The new runtime's process owns the session: only now does it have a writer. `link` is the + * provider handle when the acquire already held it; one the provider answers with later is recorded + * through `recordAgentSessionProviderHandle`, and until then the lease names no proven link. */ export function proveAgentSessionOwner(args: { record: AgentSessionRecord fence: number - link: AgentSessionProviderHandleLink + link?: AgentSessionProviderHandleLink now: number leaseTtlMs: number }): AgentSessionRecord { @@ -146,18 +147,18 @@ export function proveAgentSessionOwner(args: { reason: 'spawnIdentityMismatch' }) } - if (!agentSessionProviderHandleBelongsTo(args.link.handle, record.provider)) { + const { link } = args + if (link && !agentSessionProviderHandleBelongsTo(link.handle, record.provider)) { throw new Error('agent_session_provider_handle_provider_mismatch') } - if (args.link.mintedAtFence !== args.fence) { + if (link && link.mintedAtFence !== args.fence) { throw new Error('agent_session_provider_handle_stale_fence') } - const providerHandleChain = appendAgentSessionProviderHandleLink( - record.providerHandleChain, - args.link - ) + const providerHandleChain = link + ? appendAgentSessionProviderHandleLink(record.providerHandleChain, link) + : record.providerHandleChain const head = providerHandleChain.at(-1) - if (!head) { + if (link && !head) { throw new Error('agent_session_provider_handle_invalid') } return { @@ -166,7 +167,7 @@ export function proveAgentSessionOwner(args: { lease: { ...record.lease, handoffStage: null, - provenHandleLinkId: head.linkId, + provenHandleLinkId: link && head ? head.linkId : null, claimStatus: 'live', leaseDeadlineAt: args.now + args.leaseTtlMs, lastRenewedAt: args.now, diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index b3f19910cb4d..238da1056b73 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -745,9 +745,10 @@ describe('claim keys and unreadable rows', () => { Object.assign(record.lease, { journalCheckpoint: { epoch: 'bad', sequence: 1 } }) ], [ - 'missing live proof', + // No proof yet is a live owner whose provider has not answered; a proof naming no link is not. + 'live proof naming no handle', (record: PersistedAgentSessionRecord) => - Object.assign(record.lease, { provenHandleLinkId: null }) + Object.assign(record.lease, { provenHandleLinkId: 'link-never-minted' }) ] ])('quarantines a record with %s', async (_name, corrupt) => { const first = await open() diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 02a1ab2f63a4..572234b3a23c 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -211,7 +211,7 @@ export class AgentSessionRecordStore { async proveOwner(args: { sessionId: string fence: number - link: AgentSessionProviderHandleLink + link?: AgentSessionProviderHandleLink now: number leaseTtlMs?: number options?: Readonly> @@ -220,7 +220,7 @@ export class AgentSessionRecordStore { const proved = proveAgentSessionOwner({ record, fence: args.fence, - link: args.link, + ...(args.link ? { link: args.link } : {}), now: args.now, leaseTtlMs: args.leaseTtlMs ?? AGENT_SESSION_LEASE_TTL_MS }) diff --git a/src/main/runtime/claude-structured-failed-start-resume.test.ts b/src/main/runtime/claude-structured-failed-start-resume.test.ts index cd4f44d59f8a..9f82c959be8d 100644 --- a/src/main/runtime/claude-structured-failed-start-resume.test.ts +++ b/src/main/runtime/claude-structured-failed-start-resume.test.ts @@ -1,6 +1,6 @@ // A Claude chat whose first start died before initialize (not signed in, a crash) wrote no // transcript, so its next start launches that same provider id fresh. It is still the same -// conversation: the new child continues the record's chain rather than creating a second root. +// conversation: only the first child to prove its start creates the record's handle chain. import { afterEach, describe, expect, it, vi } from 'vitest' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' @@ -24,6 +24,7 @@ describe('a Claude chat whose first start died before initialize', () => { await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ ok: true }) + await vi.waitFor(() => expect(claude.child(SESSION).initializing).toBe(true)) claude.child(SESSION).exit(new Error('claude stream-json exited (code 1): not signed in')) await waitForStructuredAgentSessionRecovery() const failed = host.deps.store.getRecord(SESSION) @@ -37,14 +38,17 @@ describe('a Claude chat whose first start died before initialize', () => { ) expect(reopened, JSON.stringify(reopened)).toMatchObject({ ok: true }) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) const providerSessionId = claudeSessionIdForOrcaSession(SESSION) // No transcript exists to `--resume`, so the id is started fresh... expect(claude.child(SESSION).launch.options).toMatchObject({ sessionId: providerSessionId }) expect(claude.child(SESSION).launch.options.resume).toBeUndefined() - // ...and the record still holds one conversation under one root. + // ...and only this proven start records the conversation's root. expect( host.deps.store.getRecord(SESSION)?.providerHandleChain.map((link) => link.origin) - ).toEqual(['created', 'resumed']) + ).toEqual(['created']) await vi.waitFor(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') ) diff --git a/src/main/runtime/claude-structured-fake-connection-test-fixture.ts b/src/main/runtime/claude-structured-fake-connection-test-fixture.ts index 3404bcbf283c..528d8a8b7c7d 100644 --- a/src/main/runtime/claude-structured-fake-connection-test-fixture.ts +++ b/src/main/runtime/claude-structured-fake-connection-test-fixture.ts @@ -35,7 +35,12 @@ export function fakeClaude(providerSession: string) { initializationResult: async () => { connection.calls.push({ subtype: 'initialize' }) if (selfExit) { - handlers.onExit?.(new Error(selfExit.message)) + const error = new Error(selfExit.message) + if (selfExit.exitVerdict.root === 'exited') { + handlers.onExit?.(error) + } else { + handlers.onFault?.(error) + } return { models: [] } } handlers.onMessage?.({ diff --git a/src/main/runtime/claude-structured-retired-saved-model.test.ts b/src/main/runtime/claude-structured-retired-saved-model.test.ts index d4743996f63a..05730f9b6835 100644 --- a/src/main/runtime/claude-structured-retired-saved-model.test.ts +++ b/src/main/runtime/claude-structured-retired-saved-model.test.ts @@ -1,6 +1,5 @@ -// A chat's saved model rides the launch unchecked. When the CLI answers a turn by saying that model -// does not exist, the record drops it, so the next start runs the CLI's own default; the user's -// message and the CLI's own answer stay as they are. Against the production runtime, adapter, +// A chat's saved model rides the launch unchecked. A missing-model reply retires the live child's +// choice; provider reports leave the saved pick alone. Against the production runtime, adapter, // record store and host, with only the CLI process scripted. import { afterEach, describe, expect, it, vi } from 'vitest' @@ -55,17 +54,17 @@ async function startedWith(options: Record): Promise { - it('drops the model from the record, keeping the rest, so the next start runs the default', async () => { + it('keeps the saved pick while the live child retires its missing model', async () => { const host = await startedWith({ model: RETIRED, effort: 'high' }) expect(claude.child(SESSION).launch.options.model).toBe(RETIRED) expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED) claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) - - await vi.waitFor(() => - expect(host.deps.store.getRecord(SESSION)?.options).not.toHaveProperty('model') - ) - expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({ effort: 'high' }) + await host.flushStreamedEvents(SESSION) + expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({ + model: RETIRED, + effort: 'high' + }) }) it("keeps a model the reply did not come from: a subagent's own error", async () => { @@ -97,21 +96,13 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { }) expect(changed, JSON.stringify(changed)).toMatchObject({ ok: true }) expect(record()?.options?.model).toBe('opus') - - await host.handleAdapterEvent({ - type: 'options-skipped', - sessionId: SESSION, - fence: record()?.lease.runtimeFence ?? 0, - acquisitionGeneration: 'generation-heal', - options: { model: RETIRED } - }) - + claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) + await host.flushStreamedEvents(SESSION) expect(record()?.options?.model).toBe('opus') }) - // A slow settings readback lets the turn's error land first; the report that follows it still - // names the CLI's own model, which is the retired one, and must not write it back. - it('does not let a settings report that lands after the heal write the retired model back', async () => { + // A slow settings readback can report the retired live model after the error. + it('leaves the saved pick alone when that settings report arrives late', async () => { claude.behave(SESSION, { sendsNoStartFrame: true, startupSettingsReadHangs: true, @@ -132,9 +123,8 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { model: RETIRED }) claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) - await vi.waitFor(() => - expect(host.deps.store.getRecord(SESSION)?.options).not.toHaveProperty('model') - ) + await host.flushStreamedEvents(SESSION) + expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED) // The settings read gives up at its deadline and reports what the child showed. await vi.waitFor( @@ -142,6 +132,6 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { { timeout: 5_000 } ) await host.flushStreamedEvents(SESSION) - expect(host.deps.store.getRecord(SESSION)?.options).not.toHaveProperty('model') + expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED) }) }) diff --git a/src/main/runtime/claude-structured-send-during-startup.test.ts b/src/main/runtime/claude-structured-send-during-startup.test.ts index 494d012de462..823af00c33ed 100644 --- a/src/main/runtime/claude-structured-send-during-startup.test.ts +++ b/src/main/runtime/claude-structured-send-during-startup.test.ts @@ -57,10 +57,10 @@ function fence(host: StructuredAgentSessionHost): number { return host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 } -/** Whether the CLI was written a message, once the delivery loop has settled what it does. */ +/** Whether the CLI was written a message while the host is waiting on its open. */ async function written(host: StructuredAgentSessionHost): Promise { await vi.waitFor(() => - expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(true) ) return claude.child(SESSION).calls.includes('send') } diff --git a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts index a05905e9599c..ae873d22e210 100644 --- a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts +++ b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts @@ -95,6 +95,7 @@ async function submission(host: StructuredAgentSessionHost, clientMessageId: str async function failLatestStart(host: StructuredAgentSessionHost, count: number): Promise { await eventually(() => expect(claude.children(SESSION)).toHaveLength(count)) + await eventually(() => expect(claude.child(SESSION).initializing).toBe(true)) claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) await waitForStructuredAgentSessionRecovery() await eventually(() => @@ -141,9 +142,8 @@ describe('a send whose restarted Claude child dies before it proves its start', // Accepted: the answer comes before the restart it needs. expect(answered.get(sent)).toBe('pending') await eventually(() => expect(claude.children(SESSION)).toHaveLength(2)) - await eventually(() => - expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) - ) + await eventually(() => expect(claude.child(SESSION).initializing).toBe(true)) + expect(await submission(host, sent)).toMatchObject({ dispatchState: 'pending' }) expect(claude.child(SESSION).calls).not.toContain('send') await failLatestStart(host, 2) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 45baed003b13..0aad485b09b3 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -281,6 +281,7 @@ beforeEach(async () => { afterEach(async () => { vi.unstubAllEnvs() + claude.setSelfExit(null) await stopStructuredAgentSessionRuntime() await rm(root, { recursive: true, force: true }) }) @@ -402,7 +403,9 @@ describe('a structured Claude session over agentSession.*', () => { text: "Claude isn't signed in. Run `claude auth login`, or choose an account in Claude Accounts settings.", failure: { kind: 'notSignedIn', account: 'system' } }) - expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) + await vi.waitFor(() => + expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) + ) // A failed start is not auto-resumed into the same failure. expect(claude.connections).toHaveLength(1) }) @@ -410,7 +413,7 @@ describe('a structured Claude session over agentSession.*', () => { // The root's death is first-hand. Its descendants were never snapshottable, or one was seen // alive; either way the lease follows the root, so the reservation goes with it. it.each(['unverifiable', 'live'] as const)( - 'releases a session whose CLI self-exited during create with its tree %s, refused in a sentence', + 'releases a session whose CLI self-exited after spawn with its tree %s, with a readable failure', async (tree) => { claude.setSelfExit({ message: 'claude stream-json exited (code 1): claude: not signed in', @@ -419,18 +422,13 @@ describe('a structured Claude session over agentSession.*', () => { const failed = await call('agentSession.create', createIntentParams()) - // Answered once, as the refusal a replay of this operation gives, never thrown first. - expect(failed).toMatchObject({ - ok: true, - result: { - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - // The CLI's stderr is log text; the person reads what the chat's start failure says. - message: 'Claude stopped before it finished starting. Send your message to try again.', - ownerVerdict: 'exited' - } - } + expect(failed).toMatchObject({ ok: true, result: { ok: true } }) + await vi.waitFor(() => expect(leaseOf(SESSION).claimStatus).toBe('released')) + const failure = itemsOf(await subscribe()).find((item) => item.body?.kind === 'status') + expect(failure?.body).toMatchObject({ + kind: 'status', + text: 'Claude stopped before it finished starting. Send your message to try again.', + failure: { kind: 'providerStartFailed' } }) const lease = leaseOf(SESSION) // Latching here would refuse every later attach with agent_session_ownership_unknown, @@ -438,7 +436,7 @@ describe('a structured Claude session over agentSession.*', () => { expect(lease).toMatchObject({ claimStatus: 'released', handoffStage: null }) expect(lease.deathEvidence).toMatchObject({ kind: 'exit-observed', - detail: 'the provider process exited; its descendants were not proven gone' + detail: 'claude stream-json exited (code 1): claude: not signed in' }) claude.setSelfExit(null) @@ -447,7 +445,7 @@ describe('a structured Claude session over agentSession.*', () => { } ) - it('answers a create whose whole CLI tree exited as exited on the first call', async () => { + it('releases a spawned child whose whole CLI tree exited', async () => { claude.setSelfExit({ message: 'claude stream-json exited (code 1): claude: not signed in', // The common case: the close ladder proves the root and every descendant gone. @@ -456,22 +454,15 @@ describe('a structured Claude session over agentSession.*', () => { const failed = await call('agentSession.create', createIntentParams()) - expect(failed).toMatchObject({ - ok: true, - result: { - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: 'Claude stopped before it finished starting. Send your message to try again.', - ownerVerdict: 'exited' - } - } - }) - expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) + expect(failed).toMatchObject({ ok: true, result: { ok: true } }) + await vi.waitFor(() => + expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) + ) + expect(leaseOf(SESSION).deathEvidence).toMatchObject({ kind: 'exit-observed' }) claude.setSelfExit(null) }) - it('releases a failed start that recorded no owner without claiming it exited', async () => { + it('keeps the owner when a published child failure has no proof of exit', async () => { claude.setSelfExit({ message: 'claude stream-json exited (code 1): claude: not signed in', // The root was never seen to exit, so nothing proves this start's process gone. @@ -479,15 +470,16 @@ describe('a structured Claude session over agentSession.*', () => { }) await call('agentSession.create', createIntentParams()) - claude.setSelfExit(null) + await vi.waitFor(() => expect(claude.live().closed).toBe(true)) - // The adapter closed the stdio of what it spawned, and no owner was recorded to stop. The next - // start goes ahead; with no death evidence nothing reads the failed start as exited. + // Losing stdio is not proof that a published owner exited. expect(leaseOf(SESSION)).toMatchObject({ - claimStatus: 'released', + claimStatus: 'live', handoffStage: null, deathEvidence: null }) + expect(claude.connections).toHaveLength(1) + claude.setSelfExit(null) }) it.each(['unverifiable', 'live'] as const)( @@ -797,14 +789,18 @@ describe('a structured Claude session over agentSession.*', () => { handle: claudeProviderHandle(PROVIDER_SESSION, 'provider-opened-assistant'), origin: 'resumed' } - expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject( - lastCompletedTurn + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject( + lastCompletedTurn + ) ) // Open, close, open with no turn in between keeps that leaf. const reopened = await ok<{ fence: number }>('agentSession.ensure', ensureParams(resumed.fence)) expect(reopened.fence).toBe(resumed.fence + 1) - expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject( - lastCompletedTurn + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject( + lastCompletedTurn + ) ) }) diff --git a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts index e6866dcaa420..c315f3740dbb 100644 --- a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts +++ b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts @@ -101,9 +101,8 @@ describe('a Claude start that Orca fails while the CLI is still running', () => ok: true }) const held = await send(host, 'hello') - await vi.waitFor(() => - expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) - ) + await vi.waitFor(() => expect(claude.child(SESSION).initializing).toBe(true)) + expect(await submission(host, held)).toMatchObject({ dispatchState: 'pending' }) expect(claude.child(SESSION).calls).not.toContain('send') claude.child(SESSION).answerInit() diff --git a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts index 4ebec371630b..bb958dfa745c 100644 --- a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts +++ b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts @@ -56,6 +56,15 @@ async function setOption( } /** What the picker is handed: the value it shows, and which ones the CLI vouched for. */ +async function ready(host: StructuredAgentSessionHost): Promise { + await vi.waitFor( + () => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready'), + { + timeout: DEADLINE_MS * 40 + } + ) +} + async function picker(host: StructuredAgentSessionHost) { const { model, effort, confirmed } = (await host.readOptions(SESSION)).current return { model, effort, confirmed } @@ -122,10 +131,8 @@ describe('a Claude start whose CLI answers initialize but not a control request' permissionMode: 'acceptEdits' }) // The record keeps the saved choices through the start. - await vi.waitFor(() => expect(record(host)?.options).toEqual({ ...saved, effort: 'high' }), { - timeout: DEADLINE_MS * 40 - }) - expect(host.deps.adapter.readOptionRestoreFailures?.(SESSION)).toEqual([]) + await ready(host) + expect(record(host)?.options).toEqual(saved) expect(await statusRows(host)).toEqual([]) expect(claude.children(SESSION)).toHaveLength(1) expect(claude.child(SESSION).calls).not.toContain('set_model') @@ -143,12 +150,8 @@ describe('a Claude start whose CLI answers initialize but not a control request' await expect( host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'sonnet' } })) ).resolves.toMatchObject({ ok: true }) - await vi.waitFor( - () => expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high' }), - { - timeout: DEADLINE_MS * 40 - } - ) + await ready(host) + expect(record(host)?.options).toEqual({ model: 'sonnet' }) // The CLI answers again; the user sets another option on the running child. behavior.optionWritesHang = false @@ -165,29 +168,25 @@ describe('a Claude start whose CLI answers initialize but not a control request' await expect( host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'sonnet' } })) ).resolves.toMatchObject({ ok: true }) - await vi.waitFor( - () => expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high' }), - { timeout: DEADLINE_MS * 40 } - ) + await ready(host) + expect(record(host)?.options).toEqual({ model: 'sonnet' }) // The picker offers the saved model, which nothing has vouched for yet. expect(await picker(host)).toEqual({ model: 'sonnet', effort: 'high', confirmed: ['effort'] }) - // A turn shows the child running the CLI's own model: the picker follows it, the record - // keeps what the user chose. + // A turn reports its own model; the picker and record keep the next message's saved choice. turnReportsModel('claude-sonnet-5') expect(await picker(host)).toEqual({ - model: 'claude-sonnet-5', + model: 'sonnet', effort: 'high', - confirmed: ['model', 'effort'] + confirmed: ['effort'] }) - expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high' }) + expect(record(host)?.options).toEqual({ model: 'sonnet' }) behavior.optionWritesHang = false // The permission mode is the record's alone; the running child is not asked. await setOption(host, 'permissionMode', 'accept-edits') expect(record(host)?.options).toEqual({ model: 'sonnet', - effort: 'high', permissionMode: 'accept-edits' }) @@ -228,7 +227,6 @@ describe('a Claude start whose CLI answers initialize but not a control request' await vi.waitFor(() => expect(record(host)?.options).toEqual({ model: 'sonnet', - effort: 'high', permissionMode: 'accept-edits' }) ) @@ -242,12 +240,9 @@ describe('a Claude start whose CLI answers initialize but not a control request' await expect( host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'sonnet' } })) ).resolves.toMatchObject({ ok: true }) - // The record holds the saved model from creation; only the start's own report (effort) says - // startup finished, and an option write before then waits for it. - await vi.waitFor( - () => expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high' }), - { timeout: DEADLINE_MS * 40 } - ) + // Wait on host readiness; provider reports never replace saved choices. + await ready(host) + expect(record(host)?.options).toEqual({ model: 'sonnet' }) turnReportsModel('claude-sonnet-5') behavior.optionWritesHang = false @@ -256,16 +251,15 @@ describe('a Claude start whose CLI answers initialize but not a control request' expect((await picker(host)).model).toBe('opus') }) - it("lands with effort unknown when startup's own settings read goes unanswered", async () => { + it("keeps saved choices empty when startup's settings read goes unanswered", async () => { claude.behave(SESSION, { startupSettingsReadHangs: true, controlTimeoutMs: DEADLINE_MS }) const host = await claude.install() await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ ok: true }) - await vi.waitFor(() => expect(record(host)?.options).toEqual({ model: 'claude-sonnet-5' }), { - timeout: DEADLINE_MS * 40 - }) + await ready(host) + expect(record(host)?.options).toBeUndefined() expect(record(host)?.lease.claimStatus).toBe('live') expect(await statusRows(host)).toEqual([]) expect(claude.children(SESSION)).toHaveLength(1) diff --git a/src/main/runtime/orchestration/send-agent-turn.ts b/src/main/runtime/orchestration/send-agent-turn.ts index 9805a346b7ee..b7a24c4c4365 100644 --- a/src/main/runtime/orchestration/send-agent-turn.ts +++ b/src/main/runtime/orchestration/send-agent-turn.ts @@ -50,6 +50,9 @@ export type StructuredSessionTurnSend = { /** Scopes the host's operation ledger, so one sender's sends cannot exhaust another's budget. */ callerKey: string turn: StructuredSessionTurn + /** The caller's own readiness budget for the agent to take the message, its start included; + * the orchestration readiness timeout by default. */ + budgetMs?: number } /** @@ -139,8 +142,9 @@ async function sendStructuredSessionTurn( if ('queued' in result.value) { return { kind: 'queued', clientMessageId, queued: result.value.queued } } - // Accepted is not delivered: the agent may still be starting, so wait the start out. A wait - // that fails or runs out leaves the first answer standing. + // Accepted is not delivered: the agent may still be starting. One budget, the caller's, covers + // the start and the answer; a start that outlasts it leaves the message held for the agent, and + // the answer `pending`, which the caller reports as a turn start nobody observed yet. const answered = agentSessionSendSubmission(result.value) if (answered?.dispatchState !== 'pending') { return { kind: 'sent', clientMessageId, submission: answered } @@ -149,7 +153,7 @@ async function sendStructuredSessionTurn( // hand-off, which the queue sent under a fresh id. const settled = await send.host .waitForSendSettlement(send.sessionId, answered.clientMessageId, { - budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS + budgetMs: send.budgetMs ?? ORCHESTRATION_READINESS_TIMEOUT_MS }) .catch(() => undefined) return { diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index 68120a5ad359..baa48a0b281b 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -3,8 +3,10 @@ * * Three things make this different from the PTY worker path, and all three live here: * - * - The session is created directly as structured, so readiness is the attach returning ok. There - * is no boot-to-idle gap to wait on and no `tui-idle` edge to read. + * - The session is created directly as structured. Its attach returns while the agent is still + * starting, so attachment is not readiness: the preamble goes through the host's hold + * (`sendAgentTurn` waits the start out), and the worker is ready once its agent took it. There + * is no `tui-idle` edge to read. * - Nothing here keeps its agent running. The idle sweep leaves it running while its dispatch is * open, reading that from the orchestration database; once the dispatch settles the agent rests * like any chat's, and the next mail starts it. @@ -236,6 +238,8 @@ export async function sendStructuredWorkerPreamble(args: { preamble: string /** Who the task is from (`dispatchTaskSource`), shown on the worker's turn. */ from: AgentMessageSource + /** The worker start's readiness budget, which the agent's start counts against. */ + budgetMs?: number }): Promise<'accepted' | 'pending'> { const body: AgentJournalMessageItem = { kind: 'message', @@ -252,6 +256,7 @@ export async function sendStructuredWorkerPreamble(args: { host: args.host, sessionId: args.sessionId, callerKey: structuredPointerCallerKey(args.dispatchId), + ...(args.budgetMs === undefined ? {} : { budgetMs: args.budgetMs }), turn: { body, delivery: 'now', diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-start-deadline.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-start-deadline.test.ts new file mode 100644 index 000000000000..7948feb49304 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-start-deadline.test.ts @@ -0,0 +1,110 @@ +// A structured worker's setup gate and its preamble's wait for the agent share one deadline: the +// caller's timeout. Waiting the full timeout again after a slow setup outlasts the client's limit. + +import { afterEach, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { OrchestrationDb } from '../../orchestration/db' + +const preambleBudgets: (number | undefined)[] = [] + +vi.mock('./orchestration-structured-worker-session', async (importOriginal) => ({ + ...(await importOriginal>()), + sendStructuredWorkerPreamble: async (args: { budgetMs?: number }) => { + preambleBudgets.push(args.budgetMs) + return 'accepted' + } +})) +vi.mock('./orchestration/worker/worker-start-agent-placement', async (importOriginal) => ({ + ...(await importOriginal>()), + placeWorkerAgent: async (args: { mode: unknown; effects: unknown[] }) => { + // The setup a created worktree runs, with the repo's wait-for-setup policy. + args.effects.splice(0, args.effects.length, { + kind: 'setup', + action: 'started', + terminalId: 'setup_1' + }) + return { + mode: args.mode, + worktree: { id: 'wt_1' }, + terminalHandle: 'structured_worker_1', + structuredSession: { host: {}, identity: { sessionId: 's1', handle: 'structured_worker_1' } }, + setupReceipt: { startupPolicy: 'wait-for-setup', state: 'running' } + } + } +})) +vi.mock('./orchestration/worker/worker-start-validation', () => ({ + prepareLocalWorkerStart: () => ({ + agent: 'claude', + launch: { receipt: { requested: null, effective: null }, preferences: undefined } + }) +})) +vi.mock('./orchestration/worker/worker-setup-gate', () => ({ + persistGatedSetupSpawnFailure: () => false, + persistWorkerReadinessStage: () => {}, + persistWorkerSetupWaitOutcome: () => {} +})) +vi.mock('./orchestration/worker/worker-topology', async (importOriginal) => ({ + ...(await importOriginal>()), + monitorWorkerSetup: () => {} +})) +vi.mock('./orchestration/runs/dispatch-creator', () => ({ + resolveDispatchCreator: () => ({ kind: 'terminal', handle: 'term_c' }) +})) +vi.mock('../../orchestration/preamble', () => ({ buildDispatchPreamble: () => 'preamble' })) + +const { startLocalWorker } = await import('./orchestration/worker/local-worker-start') + +afterEach(() => { + vi.useRealTimers() + preambleBudgets.length = 0 +}) + +it('leaves the preamble only what the setup gate did not use of the caller’s timeout', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + const runtime = { + showTerminal: async () => ({ worktreeId: 'wt_1' }), + showManagedTerminalWorkspace: async () => ({ id: 'wt_1' }), + getNestedWorkerMaxDepth: () => 3, + getRuntimeId: () => 'epoch-1', + getTerminalOrchestrationCliCommand: () => 'orca', + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + getOrchestrationDispatchAuthority: () => ({ paneKey: 'pane', processIncarnation: 'inc' }), + getTerminalProcessIncarnation: () => 'inc', + // Setup takes 250 s of the 300 s timeout. + waitForSetupTerminalCompletion: async () => { + vi.setSystemTime(Date.now() + 250_000) + return { exitCode: 0 } + } + } + const db = { + createStartingWorkerDispatch: () => ({ + dispatch: { id: 'd1', depth: 0 }, + task: { id: 't1', spec: 'do the thing' } + }), + recordWorkerStage: () => {}, + prepareStartingWorkerAuthority: () => {}, + getWorkerDispatch: () => ({ state: 'starting' }), + markWorkerDispatchReady: () => ({ state: 'ready', stage: 'ready' }) + } + + const receipt = await startLocalWorker({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fields this start reads; the rest are optional. + params: { from: 'term_c', timeoutMs: 300_000, agent: 'claude' } as never, + mode: { + mode: 'structured', + preferred: 'structured', + reason: 'user_default', + detail: 'structured by default' + } as const, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the start path reaches only these runtime members. + runtime: runtime as unknown as OrcaRuntimeService, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the start path reaches only these db members. + db: db as unknown as OrchestrationDb, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the run id is read. + run: { id: 'run_1' } as never, + coordinator: null + }) + + expect(receipt).toMatchObject({ state: 'ready', timeoutMs: 300_000 }) + expect(preambleBudgets).toEqual([50_000]) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts index 5fce3402cc5f..3602dbb1928d 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts @@ -46,6 +46,8 @@ export async function deliverWorkerDispatchPreamble(args: { requestId: string /** The agent this worker start launched into `terminalHandle`; absent for a caller's terminal. */ launchedAgent?: string | null + /** The caller's readiness budget; a structured worker's start counts against it. */ + readinessTimeoutMs?: number }): Promise<{ prompt?: RuntimeTerminalSend['prompt'] structuredTurnStart?: WorkerTurnStartObservation @@ -86,7 +88,8 @@ export async function deliverWorkerDispatchPreamble(args: { sessionId: structuredSession.identity.sessionId, dispatchId: args.dispatchId, preamble, - from: workerTaskSource(args) + from: workerTaskSource(args), + ...(args.readinessTimeoutMs === undefined ? {} : { budgetMs: args.readinessTimeoutMs }) }) return { structuredTurnStart: diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index cf8bb51fad81..63f9e095f699 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -60,7 +60,6 @@ export async function startLocalWorker(args: { const terminal = args.params.terminal && resolveDispatchAssigneeParty(args.params.terminal, db) const params = terminal ? { ...args.params, terminal: terminal.address } : args.params const chat = terminal ? chatAssigneeOf(terminal) : null - const coordinatorPane = coordinator?.paneKey ?? null const requestedWorktree = params.worktree ?? 'current' const createsWorktree = requestedWorktree === 'new-child' || requestedWorktree === 'new-top-level' const launchParams = await resolveWorkerConfiguredAgentParams(runtime, params, async () => { @@ -161,7 +160,7 @@ export async function startLocalWorker(args: { taskRunId: run.id, // A handle-less session creates root Tasks: Task lineage is recorded by terminal only. taskCreatedByTerminalHandle: coordinator?.terminalHandle ?? undefined, - taskCreatedByPaneKey: coordinatorPane ?? undefined, + taskCreatedByPaneKey: coordinator?.paneKey ?? undefined, taskCreatedByProcessIncarnation: coordinator?.terminalHandle ? (runtime.getTerminalProcessIncarnation(coordinator.terminalHandle) ?? undefined) : undefined, @@ -206,8 +205,7 @@ export async function startLocalWorker(args: { mode = placed.mode resolvedWorktree = placed.worktree terminalHandle = placed.terminalHandle - const structuredSession = placed.structuredSession - const setupReceipt = placed.setupReceipt + const { structuredSession, setupReceipt } = placed const setupStage = { db, dispatchId: started.dispatch.id, @@ -224,15 +222,16 @@ export async function startLocalWorker(args: { persistWorkerReadinessStage(setupStage) failedStage = 'agent_readiness' - // A structured session is ready the moment its attach returns ok: there is no boot-to-idle - // gap and no terminal title to read an idle edge from. Only the repo's wait-for-setup policy - // still holds it back, and that gate has to be waited on explicitly here. + // A structured worker's setup gate and its preamble's wait share one deadline: the timeout. + const readinessDeadline = Date.now() + (params.timeoutMs ?? 60_000) + // A structured session has no boot-to-idle gap or terminal title to read: its agent's start is + // waited out by the preamble's send. Only the repo's wait-for-setup policy is waited on here. const wait = structuredSession ? await awaitStructuredWorkerSetupGate({ runtime, setup: setupReceipt, effects, - timeoutMs: params.timeoutMs ?? 60_000 + timeoutMs: Math.max(0, readinessDeadline - Date.now()) }) : chat ? // A chat takes its task as a queued send whatever it is doing, so nothing is waited on. @@ -289,6 +288,7 @@ export async function startLocalWorker(args: { launchReceipt: launch.receipt, mode, timeoutMs: params.timeoutMs ?? 60_000, + readinessDeadline, effects, terminalRevealWarning: placed.warning, onStage: (stage) => { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts index ceeae2710d4d..dca7561abe45 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts @@ -49,6 +49,7 @@ function settle(delivered: 'accepted' | undefined) { launchReceipt: {}, mode: {}, timeoutMs: 60_000, + readinessDeadline: Date.now() + 60_000, effects: [], terminalRevealWarning: undefined, onStage: () => {} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts index 87466499afe3..779634834773 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts @@ -40,7 +40,10 @@ export async function deliverAndSettleWorkerStartReadiness(args: { setupReceipt: WorkerSetupReceipt launchReceipt: OrchestrationWorkerLaunchReceipt mode: WorkerStartModeReceipt + /** The caller's timeout, as the receipt reports it. */ timeoutMs: number + /** When the caller's timeout runs out; a structured preamble waits on its agent until then. */ + readinessDeadline: number effects: WorkerEffect[] terminalRevealWarning: string | undefined /** Keeps the caller's failure receipt naming the stage that actually failed. */ @@ -62,7 +65,8 @@ export async function deliverAndSettleWorkerStartReadiness(args: { coordinatorHandle: args.coordinatorHandle, devMode: args.devMode, requestId: args.requestId, - launchedAgent: args.launchedAgent + launchedAgent: args.launchedAgent, + readinessTimeoutMs: Math.max(0, args.readinessDeadline - Date.now()) }) effects.push({ kind: 'dispatch_input', diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 136f9b259752..7f48270d0200 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -231,6 +231,7 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio const sources = structuredAgentLaunchSources(context) const part = acpStructuredLaunchPart(spec, { readJournal, + ...deps.acpLaunchCommand, ...(deps.resolveAgentFullAccess ? { resolveFullAccess: deps.resolveAgentFullAccess } : {}), logger: deps.logger }) @@ -239,18 +240,13 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio readJournal, resolveLaunch: composeStructuredLaunch(definition, sources, part), readLaunch: composeStructuredLaunchRead(definition, sources, part), - connect: (launch, options) => createAcpAgentConnection(launch, options), + connect: deps.openAcpConnection ?? createAcpAgentConnection, onChildWorkEvidence: (sessionId, evidence) => context.host()?.publishChildWorkEvidence(sessionId, evidence), ...(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) - } - } + onEvent: context.deliverLifecycle }) } } diff --git a/src/main/runtime/structured-agent-scripted-child.test-fixture.ts b/src/main/runtime/structured-agent-scripted-child.test-fixture.ts new file mode 100644 index 000000000000..4a13eac23236 --- /dev/null +++ b/src/main/runtime/structured-agent-scripted-child.test-fixture.ts @@ -0,0 +1,33 @@ +// What the registration matrix drives each agent's real adapter through: a scripted provider child +// behind the adapter's own transport seam, so the adapter, host, store and journal are the shipped +// ones and no process is ever spawned. Each agent's fixture implements this; the matrix fails for a +// registration with none. + +import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' + +export type ScriptedAgentChild = { + /** Runtime overrides that route this agent's adapter to the script, launch resolution included: + * a fixture that lets any path reach a real binary is a bug. */ + readonly deps: Partial + /** Each spawn's handshake holds until released: the protocol session it opens (a thread, an ACP + * session, Pi's session file, Claude's initialize answer) is not answered before. True by + * default; while false, a spawn's handshake answers at once. */ + holdHandshakes: boolean + /** Answers the held handshake of the newest spawn. */ + releaseHandshake(): void + /** Fails the held handshake of the newest spawn with this provider error; its process exits. */ + failHandshake(message: string): void + /** False: a prompt opens a turn that runs until it is interrupted or its child ends, as a turn + * with background work does. True by default: each prompt's turn completes at once. */ + completeTurns: boolean + /** The text of every prompt any spawn received, in order. */ + prompts(): readonly string[] + /** How many children were spawned. */ + spawns(): number + /** How many spawns reopened the conversation the previous child left (resume / load). */ + resumes(): number + /** How many children ended because Orca closed them. */ + closes(): number +} + +export type ScriptedAgentChildFactory = () => ScriptedAgentChild diff --git a/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts b/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts index d9b3601132a5..464c7bb2e84f 100644 --- a/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts +++ b/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts @@ -124,6 +124,7 @@ async function stop(turnId?: string): Promise { if (!stopped.ok) { throw new Error(JSON.stringify(stopped.refusal)) } + await host.collaboratorsForTests().serialize(SESSION, async () => {}) } /** `/compact` as the chat surface runs it; refused while the chat still owes work. */ diff --git a/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts new file mode 100644 index 000000000000..f31fdad35f4c --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts @@ -0,0 +1,187 @@ +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 { codexScriptedChild } from '../codex/codex-scripted-child.test-fixture' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + HOST_TEST_SESSION, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import type { ScriptedAgentChild } from './structured-agent-scripted-child.test-fixture' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +let root: string | undefined +const caller = { callerKey: 'scripted-codex-test' } +let operation = 0 + +function envelope(method: string, fence: number, fields: Record) { + return { + sessionId: HOST_TEST_SESSION, + clientOperationId: `${Date.now()}-${(++operation).toString(16).padStart(32, '0')}`, + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: HOST_TEST_SESSION, + fields + }) + } +} +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (root) { + await rm(root, { recursive: true, force: true }) + } +}) + +async function attachedHost(child: ScriptedAgentChild) { + root = await mkdtemp(join(tmpdir(), 'orca-scripted-codex-')) + const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root ?? '', + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), + resolveLaunchArgs: () => [], + ...child.deps + }) + const attach = hostTestAttachParams(null, { providerHandle: undefined }) + attach.envelope.clientOperationId = `${Date.now()}-${'b'.repeat(32)}` + const attached = await host.attach(caller, attach) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + return { host, fence: attached.value.fence } +} + +it('attaches at spawn and holds sends until the scripted Codex handshake answers', async () => { + const child = codexScriptedChild() + const { host, fence } = await attachedHost(child) + const body = hostTestMessage('held until Codex is ready') + const sent = await host.send(caller, { + envelope: envelope('agentSession.send', fence, { body }), + body + }) + expect(sent.ok).toBe(true) + expect(child.spawns()).toBe(1) + expect(host.deps.store.getRecord(HOST_TEST_SESSION)?.lease.ownerProcess).toMatchObject({ + pid: expect.any(Number), + spawnToken: expect.any(String) + }) + expect(child.prompts()).toEqual([]) + child.releaseHandshake() + await vi.waitFor(() => expect(child.prompts()).toEqual(['held until Codex is ready'])) +}) + +it('can Stop the scripted child during initialization and settle a held send as withdrawn', async () => { + const child = codexScriptedChild() + const { host, fence } = await attachedHost(child) + const body = hostTestMessage('never handed over') + const sent = await host.send(caller, { + envelope: envelope('agentSession.send', fence, { body }), + body + }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + expect( + (await host.cancel(caller, { envelope: envelope('agentSession.cancel', fence, {}) })).ok + ).toBe(true) + await host.collaboratorsForTests().serialize(HOST_TEST_SESSION, async () => {}) + expect(child.closes()).toBe(1) + expect(child.prompts()).toEqual([]) + const { submissions } = await host.journalSnapshot(HOST_TEST_SESSION) + expect( + submissions.find((entry) => entry.clientMessageId === sent.value.clientMessageId)?.dispatchState + ).toBe('rejected') +}) + +it('hands Codex a model and effort picked while it started before the first message', async () => { + const child = codexScriptedChild() + const { host, fence } = await attachedHost(child) + const picks = ( + [ + ['model', 'scripted-alt-model'], + ['effort', 'high'] + ] as const + ).map(([key, value]) => { + const fields = { key, value } + return host.setOption(caller, { + envelope: envelope('agentSession.setOption', fence, fields), + ...fields + }) + }) + const body = hostTestMessage('first message') + const sent = host.send(caller, { envelope: envelope('agentSession.send', fence, { body }), body }) + child.releaseHandshake() + expect(await Promise.all(picks)).toEqual([ + expect.objectContaining({ ok: true }), + expect.objectContaining({ ok: true }) + ]) + expect((await sent).ok).toBe(true) + + await vi.waitFor(() => expect(child.prompts()).toEqual(['first message'])) + const turn = child.requests().find(({ method }) => method === 'turn/start') + expect(turn?.params).toMatchObject({ model: 'scripted-alt-model', effort: 'high' }) + expect(host.deps.store.getRecord(HOST_TEST_SESSION)?.options).toMatchObject({ + model: 'scripted-alt-model', + effort: 'high' + }) +}) + +it('rejects a held send once when Codex initialization refuses and closes the child once', async () => { + const child = codexScriptedChild() + const { host, fence } = await attachedHost(child) + const body = hostTestMessage('never delivered') + const sent = await host.send(caller, { + envelope: envelope('agentSession.send', fence, { body }), + body + }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + await vi.waitFor(() => + expect(child.requests().some((request) => request.method === 'initialize')).toBe(true) + ) + child.failHandshake('Please sign in to Codex') + await vi.waitFor(async () => { + const { submissions } = await host.journalSnapshot(HOST_TEST_SESSION) + const rejected = submissions.filter( + (entry) => entry.clientMessageId === sent.value.clientMessageId + ) + expect(rejected).toHaveLength(1) + expect(rejected[0]?.dispatchState).toBe('rejected') + }) + expect(child.closes()).toBe(1) + expect(child.spawns()).toBe(1) + expect(child.prompts()).toEqual([]) +}) + +it('stops while thread/start is held and never hands over the accepted send', async () => { + const thread = Promise.withResolvers() + const child = codexScriptedChild({ 'thread/start': () => thread.promise }) + child.holdHandshakes = false + const { host, fence } = await attachedHost(child) + const body = hostTestMessage('never delivered') + const sent = await host.send(caller, { + envelope: envelope('agentSession.send', fence, { body }), + body + }) + expect(sent.ok).toBe(true) + await vi.waitFor(() => + expect(child.requests().some((request) => request.method === 'thread/start')).toBe(true) + ) + expect( + (await host.cancel(caller, { envelope: envelope('agentSession.cancel', fence, {}) })).ok + ).toBe(true) + thread.resolve({ thread: { id: 'scripted-codex-thread' } }) + await host.collaboratorsForTests().serialize(HOST_TEST_SESSION, async () => {}) + expect(child.closes()).toBe(1) + expect(child.prompts()).toEqual([]) +}) diff --git a/src/main/runtime/structured-agent-session-codex-start-deadline.test.ts b/src/main/runtime/structured-agent-session-codex-start-deadline.test.ts new file mode 100644 index 000000000000..99bf996bc874 --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-start-deadline.test.ts @@ -0,0 +1,103 @@ +// A Codex start has one deadline, the host's startup attempt (60 s without a word, 10 min at most): +// its own request bounds no longer end a slow `initialize` first. + +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 { codexScriptedChild } from '../codex/codex-scripted-child.test-fixture' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + HOST_TEST_SESSION, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { recordAgentSessionStartup } from '../observability/agent-session-instrumentation' +import type { ScriptedAgentChild } from './structured-agent-scripted-child.test-fixture' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +vi.mock('../observability/agent-session-instrumentation', async (importOriginal) => ({ + ...(await importOriginal>()), + recordAgentSessionStartup: vi.fn() +})) + +let root: string | undefined +const caller = { callerKey: 'codex-start-deadline-test' } +let operation = 0 + +beforeEach(() => { + // Real time still passes, so file and database work settles; the test jumps the clock. + vi.useFakeTimers({ + shouldAdvanceTime: true, + toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] + }) + vi.mocked(recordAgentSessionStartup).mockClear() +}) + +afterEach(async () => { + vi.useRealTimers() + await stopStructuredAgentSessionRuntime() + if (root) { + await rm(root, { recursive: true, force: true }) + } +}) + +function envelope(method: string, fence: number, fields: Record) { + return { + sessionId: HOST_TEST_SESSION, + clientOperationId: `${Date.now()}-${(++operation).toString(16).padStart(32, '0')}`, + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: HOST_TEST_SESSION, + fields + }) + } +} + +async function chatHoldingAMessage(child: ScriptedAgentChild) { + root = await mkdtemp(join(tmpdir(), 'orca-codex-start-deadline-')) + const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root ?? '', + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), + resolveLaunchArgs: () => [], + ...child.deps + }) + const attach = hostTestAttachParams(null, { providerHandle: undefined }) + attach.envelope.clientOperationId = `${Date.now()}-${'d'.repeat(32)}` + const attached = await host.attach(caller, attach) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + const body = hostTestMessage('held through a slow Codex start') + const sent = await host.send(caller, { + envelope: envelope('agentSession.send', attached.value.fence, { body }), + body + }) + expect(sent.ok).toBe(true) + return host +} + +function startupOutcomes(): string[] { + return vi.mocked(recordAgentSessionStartup).mock.calls.map(([startup]) => startup.outcome) +} + +it('delivers the held message to a Codex that answers initialize after 20 s', async () => { + const child = codexScriptedChild() + await chatHoldingAMessage(child) + await vi.advanceTimersByTimeAsync(20_000) + expect(child.closes()).toBe(0) + + child.releaseHandshake() + await vi.waitFor(() => expect(child.prompts()).toEqual(['held through a slow Codex start'])) + expect(child.spawns()).toBe(1) + expect(startupOutcomes()).toEqual(['ready']) +}) diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts index a9ba53700dd3..dbd98119d2e2 100644 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -26,6 +26,7 @@ import { neverThrowingStructuredAgentSessionLogger } 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 { StructuredAgentSessionDeliveryOrder } from '../native-chat/agent-session-wire/structured-agent-session-delivery-order' import { _resetTracerForTests, setActiveSink } from '../observability/tracer' import { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups' import { createStructuredAgentSessionLifecycleDelivery } from './structured-agent-session-lifecycle-delivery' @@ -110,9 +111,7 @@ describe('failures the desktop host used to drop', () => { sessionId: SESSION, fence: 1, acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'gpt-5' }, - restoreSkippedOptions: [], - optionRevision: 0 + reportedOptions: { model: 'gpt-5' } }) lifecycle.deliver({ type: 'ended', @@ -173,6 +172,8 @@ describe('host collaborators log with their own scope', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a step that rejects at its first serialize reads only these members. { serialize: rejectingSerialize, + sessions: new Map(), + deliveryOrder: new StructuredAgentSessionDeliveryOrder(), trackStart: (start: Promise) => start, logger: log.logger } as unknown as StructuredAgentSessionDeliveryLoopDeps 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 c55bdb2f9321..f33e9e2b6bad 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -122,16 +122,12 @@ describe('a structured codex session over agentSession.*', () => { delta: 'Buffered while the journal opens.' }) - let stopped = false - const stopping = stopStructuredAgentSessionRuntime().then(() => { - stopped = true - }) - await new Promise((resolve) => setImmediate(resolve)) - const waitedForJournalBind = !stopped openGate.resolve() await creating - await stopping - expect(waitedForJournalBind).toBe(true) + await vi.waitFor(() => + expect(harness.codex.live().calls.some((entry) => entry.method === 'thread/start')).toBe(true) + ) + await stopStructuredAgentSessionRuntime() const identity = { sessionId: SESSION, diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 6f6b38378cd2..954f995dc86d 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -523,10 +523,12 @@ describe('a structured codex session over agentSession.*', () => { createIntentParams() ) expect(created.page.items).toEqual([]) - expect(codex.live().calls[0]).toMatchObject({ - method: 'thread/start', - params: { cwd: `/repos/${WORKSPACE}` } - }) + // Create answers at spawn; the thread opens once Codex has initialized. + await vi.waitFor(() => + expect(codex.live().calls.find((call) => call.method === 'thread/start')).toMatchObject({ + params: { cwd: `/repos/${WORKSPACE}` } + }) + ) const fence = created.fence const stream = await subscribe('sub-1') diff --git a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts index 9bce63de54da..49ade95333ad 100644 --- a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts @@ -13,6 +13,7 @@ const CALLER = { callerKey: 'client-1' } let claude = createScriptedClaudeRuntime([STALLED, HEALTHY]) afterEach(async () => { + vi.restoreAllMocks() await claude.dispose() claude = createScriptedClaudeRuntime([STALLED, HEALTHY]) }) @@ -32,8 +33,9 @@ describe('a Claude child proving its start', () => { ok: true }) await vi.waitFor(() => expect(claude.child(STALLED).calls).toContain('get_settings')) - // Its `started` reaches the shared chain ahead of the exit below. - await new Promise((resolve) => setImmediate(resolve)) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(STALLED)?.child?.phase).toBe('ready') + ) claude.child(HEALTHY).exit(new Error('claude stream-json exited (code 1): crashed')) await vi.waitFor(() => @@ -42,11 +44,10 @@ describe('a Claude child proving its start', () => { // What the child proved is still recorded, from the startup it already answered. await vi.waitFor(() => - expect(host.deps.store.getRecord(STALLED)?.options).toEqual({ - model: 'claude-sonnet-5', - effort: 'high' - }) + expect(host.collaboratorsForTests().sessions.get(STALLED)?.child?.phase).toBe('ready') ) + expect(host.deps.store.getRecord(STALLED)?.options).toBeUndefined() + expect(host.deps.store.getRecord(STALLED)?.providerHandleChain).toHaveLength(1) expect(claude.child(STALLED).calls).toEqual(['get_settings']) let closed = false @@ -70,44 +71,26 @@ describe('a Claude child proving its start', () => { ok: true }) await vi.waitFor(() => - expect(host.deps.store.getRecord(STALLED)?.options).toEqual({ - model: 'claude-sonnet-5', - effort: 'high' - }) + expect(host.collaboratorsForTests().sessions.get(STALLED)?.child?.phase).toBe('ready') ) // The other start is still where it was: reserved, with no new child. expect(host.deps.store.getRecord(HEALTHY)?.lease.claimStatus).toBe('reserved') expect(claude.children(HEALTHY)).toHaveLength(childrenWhileHung) }) - it('is drained by the runtime before teardown proceeds', async () => { + it('records the proven start without overwriting saved choices with provider reports', async () => { const host = await claude.install() - const store = host.deps.store - const replaceSessionOptions = store.replaceSessionOptions.bind(store) - let landWrite = (): void => {} - const writeHeld = new Promise((resolve) => { - landWrite = resolve - }) - let writing = false - vi.spyOn(store, 'replaceSessionOptions').mockImplementation(async (input) => { - writing = true - await writeHeld - return replaceSessionOptions(input) - }) - await expect(host.attach(CALLER, claude.attachParams(HEALTHY, null))).resolves.toMatchObject({ - ok: true - }) - await vi.waitFor(() => expect(writing).toBe(true)) - - let drained = false - const recovery = waitForStructuredAgentSessionRecovery().then(() => { - drained = true - }) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(drained).toBe(false) + const replaceOptions = vi.spyOn(host.deps.store, 'replaceSessionOptions') + const saved = { model: 'opus', effort: 'low' } + await expect( + host.attach(CALLER, claude.attachParams(HEALTHY, null, { options: saved })) + ).resolves.toMatchObject({ ok: true }) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(HEALTHY)?.child?.phase).toBe('ready') + ) - landWrite() - await recovery - expect(store.getRecord(HEALTHY)?.options).toEqual({ model: 'claude-sonnet-5', effort: 'high' }) + expect(host.deps.store.getRecord(HEALTHY)?.providerHandleChain).toHaveLength(1) + expect(host.deps.store.getRecord(HEALTHY)?.options).toEqual(saved) + expect(replaceOptions).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 34a68277adc8..cf72962c67a4 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -11,7 +11,7 @@ // structured request with the refusal that says why. import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' -import type { PiRpcSessionDeps } from '../pi/rpc-session' +import type { StructuredAgentSessionTransportOverrides } from './structured-agent-session-transport-overrides' import { existsSync } from 'node:fs' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' @@ -23,9 +23,6 @@ import { } from './structured-agent-session-runtime-teardown' import { AgentSessionRecoveryCapsule } from './agent-session-recovery-capsule' import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' -import type { CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' -import type { readCodexCliInstallation } from '../preflight/codex-cli-installation' -import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' import { StructuredAgentSessionHost, type StructuredAgentSessionHostDeps @@ -89,7 +86,7 @@ export function hasPersistedStructuredAgentSessionStore( } } -export type StructuredAgentSessionRuntimeDeps = { +export type StructuredAgentSessionRuntimeDeps = StructuredAgentSessionTransportOverrides & { /** Host state root. The record store and the journal database both hang off it. */ stateDirectory: string /** Execution host this runtime *is*. A record pinned elsewhere is not ours to @@ -100,7 +97,6 @@ export type StructuredAgentSessionRuntimeDeps = { resolveWorkspacePath: (workspaceId: string) => Promise resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string resolveClaudeCommand?: () => string - openPiConnection?: PiRpcSessionDeps['openConnection'] /** Which version-gated flags a Claude CLI takes; absent never passes one. */ claudeCliFlags?: ClaudeCliFlagSupport /** Gives each chat a visuals folder and the skill that teaches it, and sweeps folders whose chat @@ -110,13 +106,6 @@ export type StructuredAgentSessionRuntimeDeps = { isEnabled: () => boolean workspaceVerdicts: NonNullable } - /** Provider transports are overridden only to drive the runtime against scripted children. */ - openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] - /** Scripted transports must also replace the real host's version probe. */ - readCodexInstallation?: typeof readCodexCliInstallation - openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] - /** Scripted app-servers carry fake pids the real start-time read cannot answer for. */ - readProcessStartTime?: CodexStructuredSessionAdapterDeps['readProcessStartTime'] /** Required, and asserted at install time — saved Arguments must never be silently omitted. */ resolveLaunchArgs: (provider: AgentSessionRecord['provider']) => Promise | string[] /** Required, and asserted at install time — an absent policy must not degrade to a guess. */ @@ -327,6 +316,7 @@ async function installOnJournal( ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), + ...(deps.startupLimits ? { startupLimits: deps.startupLimits } : {}), ...(deps.onSessionTabHidden ? { onSessionTabHidden: deps.onSessionTabHidden } : {}), ...(await modelCatalogHostDeps({ store, diff --git a/src/main/runtime/structured-agent-session-transport-overrides.ts b/src/main/runtime/structured-agent-session-transport-overrides.ts new file mode 100644 index 000000000000..e1cbf8e26bbe --- /dev/null +++ b/src/main/runtime/structured-agent-session-transport-overrides.ts @@ -0,0 +1,24 @@ +import type { AcpStructuredSessionAdapterDeps } from '../acp/acp-structured-session-adapter-deps' +import type { AcpStructuredLaunchPartDeps } from '../acp/acp-structured-launch-resolution' +import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' +import type { CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' +import type { readCodexCliInstallation } from '../preflight/codex-cli-installation' +import type { StructuredAgentSessionStartupLimits } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' +import type { PiRpcSessionDeps } from '../pi/rpc-session' + +/** Provider transports are overridden only to drive the runtime against scripted children. */ +export type StructuredAgentSessionTransportOverrides = { + openPiConnection?: PiRpcSessionDeps['openConnection'] + openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] + /** Scripted transports must also replace the real host's version probe. */ + readCodexInstallation?: typeof readCodexCliInstallation + openAcpConnection?: AcpStructuredSessionAdapterDeps['connect'] + /** Answers an ACP launch's command lookup and version probe for a scripted child; every launch + * still resolves and checks through them. Unset, the real binary is found and probed. */ + acpLaunchCommand?: Pick + openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] + /** Scripted app-servers carry fake pids the real start-time read cannot answer for. */ + readProcessStartTime?: CodexStructuredSessionAdapterDeps['readProcessStartTime'] + /** The shipped startup limits unless a test shortens them. */ + startupLimits?: Partial +} diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index 5aafd5711ad9..27359034f7e5 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -125,7 +125,8 @@ beforeEach(async () => { }), openClaudeConnection: claude.openConnection, readProcessStartTime: async () => HOST_TEST_NOW, - onLifecycleEvent: () => {} + // A rewind runs only once the agent has proven its start, which the host learns from these. + onLifecycleEvent: (event) => void host.handleAdapterEvent(event) }) log = recordingStructuredAgentSessionLogger() // Only Claude sessions are attached here; the router supplies the production create gate. diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index ae489b9a6013..ddecee82e6e7 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -43,11 +43,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { export function structuredClaudeLifecycleEvent( event: ClaudeStructuredSessionEvent ): StructuredAgentSessionLifecycleEvent | null { - if ( - event.type === 'started' || - event.type === 'options-reported' || - event.type === 'options-skipped' - ) { + if (event.type === 'options-reported') { return event } // Every exit of a child with an identity, expected or not: the host ends that child's record. @@ -66,9 +62,7 @@ export function structuredClaudeLifecycleEvent( fence: event.fence, acquisitionGeneration: event.acquisitionGeneration, // The instant the translator ended the open turn at; the host reads the exit's turn by it. - ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), - ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}), - ...(event.startupUnanswered ? { startupUnanswered: event.startupUnanswered } : {}) + ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }) } } return null diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index b4fd229e3aca..b776140c68df 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -55,6 +55,7 @@ export type ScriptedClaudeChild = { sessionId: string launch: ClaudeStreamJsonLaunch calls: string[] + initializing: boolean handlers: ClaudeStreamJsonConnectionHandlers connection: Omit & { closed: boolean @@ -116,6 +117,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { sessionId, launch, calls: [], + initializing: false, handlers, exit: (error) => { child.connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } @@ -128,6 +130,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { closed: false, exitVerdict: { root: 'live', tree: 'unverifiable' }, initializationResult: () => { + child.initializing = true const initialized = { models: [{ value: 'sonnet', displayName: 'Sonnet' }] } const announce = (): void => { if (behavior.sendsNoStartFrame) { diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts index e292c271e3e9..4056bad66d5e 100644 --- a/src/main/runtime/structured-codex-child-work-runtime.test.ts +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -102,6 +102,9 @@ describe('structured Codex child work through the production runtime', () => { attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams) expect(attached).toMatchObject({ ok: true }) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) // Creating the session starts its child; nothing else has to keep it running. expect(connections).toHaveLength(1) const notify = (method: string, params: Record) => diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx index 18d99fc98b92..4e3ce97f6aa5 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx @@ -203,7 +203,7 @@ function NativeChatSessionOptionPickersInner({ pickerRequest, focusComposer }: NativeChatSessionOptionPickersProps): React.JSX.Element | null { - const [pendingId, setPendingId] = useState(null) + const [pendingIds, setPendingIds] = useState>(() => new Set()) const modelMenuFocus = useNativeChatPickerFocusReturn(focusComposer) const optionsMenuFocus = useNativeChatPickerFocusReturn(focusComposer) const model = snapshot.find((descriptor) => descriptor.category === 'model') @@ -211,18 +211,30 @@ function NativeChatSessionOptionPickersInner({ if (!surface || !model) { return null } + const modelPending = pendingIds.has(model.id) const requestedModelSequence = pickerRequest?.id === model.id ? pickerRequest.sequence : null const requestedOptionsSequence = options.some((descriptor) => descriptor.id === pickerRequest?.id) ? (pickerRequest?.sequence ?? null) : null + const markPending = (id: string) => (pendingId: string | null) => { + setPendingIds((current) => { + const next = new Set(current) + if (pendingId === null) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } const setOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => { - runSessionOptionSurfaceCall(descriptor.id, setPendingId, () => + runSessionOptionSurfaceCall(descriptor.id, markPending(descriptor.id), () => surface.setOption(descriptor.id, value) ) } const invokeAction = (descriptor: SessionOptionDescriptor): void => { - runSessionOptionSurfaceCall(descriptor.id, setPendingId, () => + runSessionOptionSurfaceCall(descriptor.id, markPending(descriptor.id), () => surface.invokeAction(descriptor.id) ) } @@ -234,7 +246,7 @@ function NativeChatSessionOptionPickersInner({ const modelTrigger = { label: nativeChatModelPillLabel(model), tooltipLabel: translate('components.native-chat.composer.model', 'Model'), - disabled: isWorking || pendingId !== null, + disabled: isWorking || modelPending, disabledReason: modelReason, dispatched: sessionOptionDispatchUnconfirmed(model) } @@ -252,7 +264,7 @@ function NativeChatSessionOptionPickersInner({ defaultOpen={modelPickerDefaultOpen} choices={model.kind.choices} currentValue={model.kind.currentValue} - readOnly={!model.settable || pendingId !== null} + readOnly={!model.settable || modelPending} readOnlyReason={modelReason} onSelect={(value) => setOption(model, value)} focusComposer={focusComposer} @@ -275,7 +287,7 @@ function NativeChatSessionOptionPickersInner({ ) : null} setOption(model, value)} invokeAction={() => invokeAction(model)} notePick={modelMenuFocus.notePick} @@ -292,7 +304,7 @@ function NativeChatSessionOptionPickersInner({ Trigger={DropdownMenuTrigger} label={nativeChatOptionsPillLabel(options)} tooltipLabel={optionsTooltip} - disabled={isWorking || pendingId !== null} + disabled={isWorking || options.every((descriptor) => pendingIds.has(descriptor.id))} disabledReason={optionsReason} dispatched={options.some(sessionOptionDispatchUnconfirmed)} /> @@ -318,7 +330,7 @@ function NativeChatSessionOptionPickersInner({ ) : null} setOption(descriptor, value)} invokeAction={() => invokeAction(descriptor)} notePick={optionsMenuFocus.notePick} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 2445b0d70b17..7ed6ba234461 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -88,7 +88,8 @@ export function NativeChatStructuredSession( hostStopping: hostExecution.stopping, hostOptionStamp: hostExecution.optionStamp, providerStarting: hostExecution.phase === 'starting', - providerRunning: hostExecution.phase !== null, + // A start's own verdict (a signed-out agent) exists only once that start is proven. + providerRunning: hostExecution.phase === 'ready', rewind: rewindHost, transportEnabled: provisionalLaunch.transportEnabled, ...(provisionalLaunch.launch ? { launch: provisionalLaunch.launch } : {}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx index 0d39b2f1198a..d470b723c0ba 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx @@ -184,6 +184,16 @@ describe('a chat pane over its own launch', () => { publishedTabs = [publishedSnapshot('wt-refused', sessionId)] readState = sessionState(1) + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' + ? Promise.resolve({ models: SAVED_LIST.models, current: { model: 'gpt-5.5' } }) + : method === 'agentSession.setOption' + ? Promise.resolve({ + ok: false, + refusal: { code: 'invalid_option', message: 'GPT-5.6 Luna is not available' } + }) + : answerCatalog(method) + ) await act(async () => { created({ sessionId, fence: 1 }) await launch.launchResult @@ -193,6 +203,6 @@ describe('a chat pane over its own launch', () => { expect(mocks.toastError).toHaveBeenCalledWith("The setting wasn't changed.") ) // Reverted to what the chat runs; the refusal never kept the launch from publishing. - expect(currentModel(result.current.optionSnapshot)).toBe('gpt-5.5') + await waitFor(() => expect(currentModel(result.current.optionSnapshot)).toBe('gpt-5.5')) }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts index 193037770d4d..84707aaddbc3 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts @@ -23,42 +23,27 @@ export type StructuredAgentSessionLaunchView = { const NO_HELD_OPTIONS: Readonly> = {} -type LatchedLaunch = { - kind: StructuredAgentSessionLaunchView['kind'] - seed: Readonly> | undefined -} - -/** The launch this view started, latched: its record is deleted on publish, and a reopened chat - * runs its own options. The seed follows the launch while it lives (a retry or accepted pick). */ -function useLatchedLaunchView( +function useLaunchView( sessionId: string, worktreeId: string | null | undefined, launching: boolean ): StructuredAgentSessionLaunchView | undefined { const selection = useStructuredAgentSessionLaunchSelection(sessionId) - const [latched, setLatched] = useState(() => - launching - ? { - kind: getStructuredAgentSessionLaunchResumes(sessionId) ? 'resume' : 'new', - seed: selection?.seed - } - : null + const [kind] = useState(() => + launching ? (getStructuredAgentSessionLaunchResumes(sessionId) ? 'resume' : 'new') : null ) - if (latched && selection && selection.seed !== latched.seed) { - setLatched({ kind: latched.kind, seed: selection.seed }) - } const held = selection?.held ?? NO_HELD_OPTIONS return useMemo( () => - latched + kind ? { - kind: latched.kind, - ...(latched.seed ? { seedOptions: latched.seed } : {}), + kind, + ...(selection?.seed ? { seedOptions: selection.seed } : {}), heldOptions: held, ...(worktreeId ? { worktree: toRuntimeWorktreeSelector(worktreeId) } : {}) } : undefined, - [held, latched, worktreeId] + [held, kind, selection?.seed, worktreeId] ) } @@ -68,7 +53,7 @@ export function useNativeChatProvisionalLaunch( ) { const lifecycle = useStructuredAgentSessionLaunchLifecycle(worktreeId ?? '', sessionId) const failure = useStructuredAgentSessionLaunchFailure(worktreeId ?? '', sessionId) - const launch = useLatchedLaunchView(sessionId, worktreeId, lifecycle !== null) + const launch = useLaunchView(sessionId, worktreeId, lifecycle !== null) const retry = useCallback(() => { if (worktreeId) { retryStructuredAgentSessionLaunch(worktreeId, sessionId) 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 a702aa38fa62..b5fdd7bdf378 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 @@ -14,6 +14,7 @@ import { import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { readHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots' +import { cloneNativeChatSessionOptionRecord } from '../../../../shared/native-chat-session-option-state' import { createCoalescedPollRunner, type CoalescedPollRunner @@ -32,7 +33,7 @@ function firstFrameOptionState( return snapshot ? applyStructuredAgentSessionModelCatalog(seeded, seed, snapshot, launch) : seeded } -/** The picker's option state for one session: seeded, reset at each fence, re-read each turn. */ +/** The picker's option state for one session: seeded, kept across its fences, re-read each turn. */ export function useStructuredAgentSessionOptionState(args: { agent: AgentType optionCatalog: AgentSessionOptionCatalog | null @@ -118,10 +119,15 @@ export function useStructuredAgentSessionOptionState(args: { seedsModel: launch.seedsModel, ...(launch.worktree ? { worktree: launch.worktree } : {}) }) - // A host answer is the account's, not the fence's: keep it rather than blank the default. + // The chat's next runtime starts with the picks the host saved, which this view last showed, + // and a starting agent is not read: keep them, and the account's list, until a read replaces them. const kept = - sameSession && (previous.catalogSource === 'host' || previous.catalogSource === 'builtin') - ? { ...seeded, catalog: previous.catalog, catalogSource: previous.catalogSource } + sameSession && previous.catalogSource !== 'seed' && previous.catalogSource !== null + ? { + ...previous, + record: cloneNativeChatSessionOptionRecord(previous.record), + pendingId: null + } : seeded // The chat's own, which a restart for a new mode keeps: the picker never blanks between them. const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx index 1f3089fa26ce..f86b60f488c2 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx @@ -497,6 +497,34 @@ describe('useStructuredAgentSessionOptions', () => { }) describe('before the provider starts', () => { + it('keeps a picked effort visible after a model pick during startup', async () => { + answer({ + options: async () => ({ + models: ['opus', 'sonnet'].map((id) => ({ + id, + label: id, + efforts: ['low', 'high'].map((value) => ({ value, label: value })) + })), + current: { model: 'opus' } + }) + }) + const { result, unmount } = renderOptions( + { ...REOPENED, agent: 'claude', providerStarting: true }, + mutateWith(async () => new Promise(() => {})).mutate + ) + await waitFor(() => expect(currentValue(result.current.optionSnapshot, 'model')).toBe('opus')) + act(() => { + void result.current.setStructuredOption('effort', 'low') + }) + expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('low') + act(() => { + void result.current.setStructuredOption('model', 'sonnet') + }) + expect(currentValue(result.current.optionSnapshot, 'model')).toBe('sonnet') + expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('low') + unmount() + }) + // What a started Claude host reports: the model its settings or env make Claude run. const CLAUDE_STARTED = { models: [ 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 c2021a3008ff..b826462c04a0 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 @@ -62,6 +62,7 @@ export function useStructuredAgentSessionOptions(args: { transportEnabled, turnId } = args + const providerStarting = args.providerStarting ?? false const launchSeedOptions = launch?.seedOptions 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. @@ -88,7 +89,7 @@ export function useStructuredAgentSessionOptions(args: { ...(launch?.worktree ? { worktree: launch.worktree } : {}), seedsModel: launchSeedOptions?.model !== undefined, providerVisible, - providerStarting: args.providerStarting ?? false, + providerStarting, hostOptionStamp: args.hostOptionStamp ?? '', // A new chat's host knows nothing of its model before the provider starts; a resumed one's // holds the model it ran, so only the former waits rather than show the host's guess. @@ -137,7 +138,7 @@ export function useStructuredAgentSessionOptions(args: { activeOptionRecordRef.current === targetRecord && optionMutationGeneration.current === mutationGeneration // Record-only: shown at once, so the picker stays usable while the write waits behind a start. - const shownAtOnce = id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID + const shownAtOnce = providerStarting || id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID if (shownAtOnce) { updateOptionState((current) => current.record === targetRecord @@ -206,6 +207,7 @@ export function useStructuredAgentSessionOptions(args: { optionMutationGeneration, optionStateRef, pendingOptionRef, + providerStarting, rememberOptionPicks, sessionId, target, @@ -238,11 +240,12 @@ export function useStructuredAgentSessionOptions(args: { const setStructuredOption = useCallback( async (id: string, value: string | boolean): Promise => { const view = structuredAgentSessionOptionView(optionStateRef.current, launchSeedOptions, held) + const pickState = { ...view, pendingId: providerStarting ? null : pendingOptionRef.current } const encoded = encodeStructuredAgentSessionOptionValue(id, value) if ( !optionCatalog || encoded === null || - !canSetStructuredAgentSessionOption(view, id, value) + !canSetStructuredAgentSessionOption(pickState, id, value) ) { return false } @@ -252,9 +255,6 @@ export function useStructuredAgentSessionOptions(args: { void applied?.then(settleLaunchOptionPick) return applied !== null } - if (pendingOptionRef.current !== null) { - return false - } return sendStructuredOption(id, encoded) }, [ @@ -264,6 +264,7 @@ export function useStructuredAgentSessionOptions(args: { optionCatalog, optionStateRef, pendingOptionRef, + providerStarting, sendStructuredOption, sessionId, settleLaunchOptionPick, 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 8e665d1c3f8c..d5cf7ff42200 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 @@ -53,7 +53,7 @@ export function useStructuredAgentSession(args: { transportEnabled?: boolean /** The host has published the session but its provider has not answered startup yet. */ providerStarting?: boolean - /** The host runs the session's provider, started or not. */ + /** The host runs the session's provider and its start is proven. */ providerRunning?: boolean /** This view started the session; only then does the stored selection name what it runs. */ launch?: StructuredAgentSessionLaunchView diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 185c62c8333e..a3ad810e1c6c 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -388,7 +388,9 @@ export function isPersistedAgentSessionRecord( validated.providerContextBoundary.afterFence <= validated.lease.runtimeFence) && (validated.lease.claimStatus !== 'live' || (validated.lease.ownerProcess !== null && - head?.linkId === validated.lease.provenHandleLinkId && - head.mintedAtFence === validated.lease.runtimeFence)) + // Null: the owner's process is live and its provider has not answered with a handle yet. + (validated.lease.provenHandleLinkId === null || + (head?.linkId === validated.lease.provenHandleLinkId && + head.mintedAtFence === validated.lease.runtimeFence)))) ) } diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 5920e68ae173..4c7627354ca7 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -9,6 +9,7 @@ import { applyNativeChatReportedSessionOptions, clearTrackedSessionOption, createNativeChatSessionOptionRecord, + getTrackedSessionOption, setTrackedSessionOption, type NativeChatSessionOptionRecord } from './native-chat-session-option-state' @@ -213,7 +214,7 @@ export function canSetStructuredAgentSessionOption( const descriptor = structuredAgentSessionOptionSnapshot(state).find((entry) => entry.id === id) return Boolean( state.catalog && - state.pendingId === null && + state.pendingId !== id && ((typeof value === 'string' && descriptor?.kind.type === 'select' && descriptor.kind.choices.some((choice) => choice.value === value)) || @@ -241,7 +242,13 @@ export function commitStructuredAgentSessionOption( if (decoded === null) { return { ...state, pendingId: null } } + const effort = + id === 'model' ? getTrackedSessionOption(state.record, effectiveModel, 'effort') : undefined setTrackedSessionOption(state.record, id, decoded, 'dispatched', effectiveModel) + // The host saves effort for the conversation, including across model picks. + if (effort) { + setTrackedSessionOption(state.record, 'effort', effort.value, 'dispatched') + } return { ...state, pendingId: 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 d29a36c2da7d..9f7c48f20f1c 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 @@ -17,6 +17,7 @@ import { join } from 'node:path' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { StructuredAgentSessionAdapter } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' +import { startsWhenPublished } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support' import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness' @@ -41,6 +42,7 @@ import { } from './structured-agent-session-surface-execution' import { installableHost, + readyCodexProviderAdapter, structuredHostStub, turnItemSkew } from './structured-agent-session-host-fixture' @@ -76,7 +78,11 @@ import { describeReleasedStopNoteProjection } from './cross-version-stop-note-sc // Why: a cold CI run extracts the baseline checkout before the first pairing. const SUITE_TIMEOUT_MS = 180_000 -describeReleasedStopNoteProjection({ build: () => current, callBuild, runtimeStub }) +describeReleasedStopNoteProjection({ + build: () => current, + callBuild, + runtimeStub +}) const CLIENT_CAPABILITY_UPDATE_METHOD = 'runtime.clientCapabilities.update' @@ -150,7 +156,9 @@ describe('cross-version structured agent sessions', () => { expect(replies, `${method} must answer exactly once`).toHaveLength(1) expect(replies[0]).toMatchObject({ ok: false, - error: { message: expect.stringContaining('structured_agent_session_unsupported') } + error: { + message: expect.stringContaining('structured_agent_session_unsupported') + } }) } for (const [name, spy] of Object.entries(hostCalls)) { @@ -213,7 +221,9 @@ describe('cross-version structured agent sessions', () => { expect(replies).toHaveLength(1) expect(replies[0]).toMatchObject({ ok: false, - error: { message: expect.stringContaining('structured_agent_session_unsupported') } + error: { + message: expect.stringContaining('structured_agent_session_unsupported') + } }) if (baseline.methodNames.includes('agentSession.createSupport')) { // Older clients read the existing refusal fields and ignore additive error metadata. @@ -230,7 +240,10 @@ describe('cross-version structured agent sessions', () => { ) expect(replies).toHaveLength(1) // `supported` is all an older desktop or phone reads; the seed rides beside it. - expect(replies[0]).toMatchObject({ ok: true, result: { supported: true, seedOptions: SEED } }) + expect(replies[0]).toMatchObject({ + ok: true, + result: { supported: true, seedOptions: SEED } + }) }) }) @@ -243,7 +256,10 @@ describe('cross-version structured agent sessions', () => { for (const [clientCapabilities, item] of turnItemSkew.clients(baseline, current)) { const client = { clientKind: 'runtime' as const, clientCapabilities } const replies = await callBuild(current, 'agentSession.history', params, client) - expect(replies[0]).toMatchObject({ ok: true, result: { page: { items: [item] } } }) + expect(replies[0]).toMatchObject({ + ok: true, + result: { page: { items: [item] } } + }) } }) }) @@ -354,9 +370,18 @@ describe('cross-version structured agent sessions', () => { const fields = { itemId: 'item-1', expectedRevision: 1, - answers: [{ questionId: 'q1', optionIds: [], other: 'Wait for the capture. '.repeat(80) }] + answers: [ + { + questionId: 'q1', + optionIds: [], + other: 'Wait for the capture. '.repeat(80) + } + ] + } + const params = { + envelope: envelope({ method, fields, fence: 1 }), + ...fields } - const params = { envelope: envelope({ method, fields, fence: 1 }), ...fields } expect(current.capabilities).toContain(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY) for (const build of [current, baseline]) { const advertised = build.capabilities.includes( @@ -436,7 +461,9 @@ describe('cross-version structured agent sessions', () => { expect(replies).toHaveLength(1) expect(replies[0]).toMatchObject({ ok: true, - result: { clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] } + result: { + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } }) expect(updates).toEqual([[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]]) }) @@ -468,31 +495,18 @@ describe('cross-version structured agent sessions', () => { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-cross-version-ai-vault-')) store = await openTestAgentSessionRecordStore(root) - const host = new StructuredAgentSessionHost({ + const host: StructuredAgentSessionHost = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire: async ({ fence }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-vault' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - }), - dispatch: async () => ({ state: 'accepted' }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => undefined, - setOption: async () => undefined - }, + adapter: readyCodexProviderAdapter({ + store, + sessionId: SESSION, + thread: THREAD, + now: NOW, + spawnToken: 'spawn-vault', + host: () => host + }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-vault', @@ -607,8 +621,14 @@ describe('cross-version structured agent sessions', () => { await callBuild( current, 'session.tabs.createTerminal', - { worktree: `id:${WORKSPACE}`, command: `codex resume '${THREAD}'` }, - { clientKind: 'runtime', clientCapabilities: legacyClientCapabilities() }, + { + worktree: `id:${WORKSPACE}`, + command: `codex resume '${THREAD}'` + }, + { + clientKind: 'runtime', + clientCapabilities: legacyClientCapabilities() + }, runtime ) )[0] @@ -618,8 +638,15 @@ describe('cross-version structured agent sessions', () => { await callBuild( current, 'terminal.send', - { terminal: 'terminal-1', text: `codex resume '${THREAD}'`, enter: true }, - { clientKind: 'runtime', clientCapabilities: legacyClientCapabilities() }, + { + terminal: 'terminal-1', + text: `codex resume '${THREAD}'`, + enter: true + }, + { + clientKind: 'runtime', + clientCapabilities: legacyClientCapabilities() + }, runtime ) )[0] @@ -636,7 +663,10 @@ describe('cross-version structured agent sessions', () => { current, 'session.tabs.createTerminal', { worktree: `id:${WORKSPACE}`, command: 'echo unrelated' }, - { clientKind: 'runtime', clientCapabilities: legacyClientCapabilities() }, + { + clientKind: 'runtime', + clientCapabilities: legacyClientCapabilities() + }, runtime ) )[0] @@ -652,62 +682,81 @@ describe('cross-version structured agent sessions', () => { /** Holds a provider start open, so a reply's timing can be read against it. */ let startGate: Promise = Promise.resolve() + /** Holds a published child's handshake: it proves its start only once this settles. */ + let handshake: Promise = Promise.resolve() let starts = 0 - /** Phase 2 owns provider processes; the adapter is the only stub here. */ - function adapter(): StructuredAgentSessionAdapter { - return { - // Every real adapter answers this; without it adapterSupportsCreate falls through to - // `supportsLocation`, which this fake also lacks, so the client-supplied-location gate - // refused for the fake's silence rather than for the location. - supportsCreate: () => true, - acquire: async ({ fence }) => { - starts += 1 - await startGate - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - 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', - mintedAtFence: fence, - observedAt: NOW + /** Phase 2 owns provider processes; the adapter is the only stub here. A ready provider + * proves its start once its handshake answers. */ + function adapter(host: () => StructuredAgentSessionHost): StructuredAgentSessionAdapter { + return startsWhenPublished( + { + // Every real adapter answers this; without it adapterSupportsCreate falls through to + // `supportsLocation`, which this fake also lacks, so the client-supplied-location gate + // refused for the fake's silence rather than for the location. + supportsCreate: () => true, + open: async () => { + await handshake + return { reportedOptions: { model: '' } } + }, + acquire: async ({ fence }) => { + starts += 1 + await startGate + return { + providerChildPhase: 'starting' as const, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + 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', + mintedAtFence: fence, + observedAt: NOW + } } - } + }, + dispatch: async () => ({ + state: 'accepted', + providerIdentity: { + provider: 'codex', + threadId: THREAD, + turnId: 'turn-1', + ordinal: 1 + } + }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => undefined, + setOption: async () => undefined }, - dispatch: async () => ({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => undefined, - setOption: async () => undefined - } + host + ) } /** Reopens the store from disk and installs a fresh host over the same journal * root — what a process restart actually leaves behind. */ - async function bootHost(generation: string): Promise { + async function bootHost(generation: string, startupLimits?: { silenceMs: number }) { store = await openTestAgentSessionRecordStore(root) - const host = new StructuredAgentSessionHost({ + const host: StructuredAgentSessionHost = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: adapter(), + adapter: adapter(() => host), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${generation}`, // The provider died with the host that spawned it, which is what makes // the restarted host the legitimate next writer. probeOwner: async () => ({ outcome: 'pid-absent' }), - now: () => NOW + now: () => NOW, + ...(startupLimits ? { startupLimits } : {}) }) setStructuredAgentSessionHost(host) return host @@ -766,6 +815,7 @@ describe('cross-version structured agent sessions', () => { beforeEach(async () => { resetOperationIds() startGate = Promise.resolve() + handshake = Promise.resolve() root = await mkdtemp(join(tmpdir(), 'orca-cross-version-agent-session-')) runtime = runtimeStub() await bootHost('a') @@ -787,14 +837,20 @@ describe('cross-version structured agent sessions', () => { await restarted.restoreReadableSessions() // Restart restores the session for READING. The chat the client still has open takes its // hold, and that is what gives the session a provider child again. - await answer('agentSession.hold', { sessionId: SESSION, holderId: 'surface-1' }) + await answer('agentSession.hold', { + sessionId: SESSION, + holderId: 'surface-1' + }) const resumedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 expect(resumedFence).toBeGreaterThan(created.fence) const second = await answer('agentSession.send', sendParams('after restart', resumedFence)) expect(second.ok).toBe(true) const events = ( - await call('agentSession.subscribe', { sessionId: SESSION, cursor: held }) + await call('agentSession.subscribe', { + sessionId: SESSION, + cursor: held + }) ).map((reply) => reply.result as AgentSessionSubscribeEvent) expect(events.map((event) => event.type)).toEqual(['batch']) const batch = events[0]?.type === 'batch' ? events[0].batch : null @@ -860,7 +916,11 @@ describe('cross-version structured agent sessions', () => { ]) expect(currentReply[0]).toMatchObject({ ok: true, - result: { value: { submission: { dispatchState: 'pending', handoverRecorded: true } } } + result: { + value: { + submission: { dispatchState: 'pending', handoverRecorded: true } + } + } }) open() await vi.waitFor(async () => @@ -871,5 +931,25 @@ describe('cross-version structured agent sessions', () => { ).toBe(true) ) }) + + // The create a released client sends answers at spawn now, so its first send's reply carries + // the handshake instead. A start that never proves itself is ended by the startup limit, and + // the reply answers with the message's rejection rather than hanging. + it("answers a released client's send once the startup limit ends a start that never proves itself", async () => { + const released = baseline.capabilities.filter( + (capability) => capability !== AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY + ) + const created = await answer('agentSession.create', createIntentParams()) + await bootHost('b', { silenceMs: 200 }) + handshake = new Promise(() => undefined) + + const params = sendParams('never started', created.fence) + const [reply] = await call('agentSession.send', params, released) + + expect(reply).toMatchObject({ + ok: true, + result: { value: { submission: { dispatchState: 'rejected' } } } + }) + }) }) }) 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 dc297fcac679..a24e11d902f3 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,4 +1,8 @@ import { vi } from 'vitest' +import type { StructuredAgentSessionAdapter } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter' +import { startsWhenPublished } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support' +import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' +import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' 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' @@ -165,3 +169,40 @@ export const turnItemSkew = { ] as const } } + +/** A Codex provider that is ready as soon as it is published, as one with an instant handshake is, + * for a real host that only needs a running agent behind a chat. */ +export function readyCodexProviderAdapter(input: { + store: Pick + sessionId: string + thread: string + now: number + spawnToken: string + host: () => StructuredAgentSessionHost +}): StructuredAgentSessionAdapter { + const { store, now } = input + return startsWhenPublished( + { + acquire: async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: now, + spawnToken: store.getRecord(input.sessionId)?.lease.reservedSpawnToken ?? input.spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(input.thread), + origin: 'created', + mintedAtFence: fence, + observedAt: now + } + }), + dispatch: async () => ({ state: 'accepted' }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => undefined, + setOption: async () => undefined + }, + input.host + ) +} diff --git a/tests/e2e/native-chat-startup-option-pickers.unit.test.ts b/tests/e2e/native-chat-startup-option-pickers.unit.test.ts new file mode 100644 index 000000000000..c82ffcc9a6c3 --- /dev/null +++ b/tests/e2e/native-chat-startup-option-pickers.unit.test.ts @@ -0,0 +1,181 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { createElement } from 'react' +import { afterEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../src/shared/agent-session-mutation-envelope' +import { claudeSessionIdForOrcaSession } from '../../src/main/claude/claude-structured-launch-resolution' +import { hostTestMessage } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { StructuredAgentSessionStartupAttempts } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt' +import { createScriptedClaudeRuntime } from '../../src/main/runtime/structured-claude-scripted-runtime-test-support' +import { TooltipProvider } from '../../src/renderer/src/components/ui/tooltip' +import { NativeChatSessionOptionPickers } from '../../src/renderer/src/components/native-chat/NativeChatSessionOptionPickers' +import { structuredAgentSessionSeedCatalog } from '../../src/shared/structured-agent-session-seed-catalog' +import { + applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../src/shared/structured-agent-session-options' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (text, [key, value]) => text.replaceAll(`{{${key}}}`, String(value)), + fallback + ) +})) +afterEach(() => cleanup()) + +it('picks model then effort through the menus during startup and uses both in the first reply', async () => { + const SESSION = 'picker-startup-pair' + const CALLER = { callerKey: 'picker-client' } + const claude = createScriptedClaudeRuntime([SESSION]) + const picked: Promise[] = [] + let operation = 0 + claude.behave(SESSION, { initHangs: true, sendsNoStartFrame: true }) + try { + const host = await claude.install() + await host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'opus' } })) + await vi.waitFor(() => expect(claude.child(SESSION).initializing).toBe(true)) + const child = claude.child(SESSION) + let replyModel = 'opus' + let replyEffort = 'high' + let firstReply: { model: string; effort: string } | undefined + child.connection.supportedModels = async () => + ['sonnet', 'opus'].map((value) => ({ + value, + displayName: value, + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + })) + child.connection.setModel = async (value) => { + replyModel = value ?? 'opus' + } + child.connection.applyFlagSettings = async (settings) => { + if (typeof settings.effortLevel === 'string') { + replyEffort = settings.effortLevel + } + } + child.connection.getSettings = async () => ({ effective: { effortLevel: replyEffort } }) + child.connection.send = async (_message, beforeDispatch) => { + await beforeDispatch?.() + child.calls.push('send') + firstReply = { model: replyModel, effort: replyEffort } + child.handlers.onMessage?.({ + type: 'assistant', + session_id: claudeSessionIdForOrcaSession(SESSION), + uuid: 'first-reply', + message: { + role: 'assistant', + model: replyModel, + content: [{ type: 'text', text: firstReply.effort }] + } + }) + } + // Let delivery wake before the callers waiting on the same ready child resume. + const readiness = StructuredAgentSessionStartupAttempts.prototype.readiness + const delayed = vi + .spyOn(StructuredAgentSessionStartupAttempts.prototype, 'readiness') + .mockImplementation(function ( + this: StructuredAgentSessionStartupAttempts, + sessionId, + identity + ) { + const selected = readiness.call(this, sessionId, identity) + return selected + ? { + wait: async (budget) => { + const outcome = await selected.wait(budget) + await new Promise((resolve) => setTimeout(resolve, 0)) + return outcome + } + } + : null + }) + const envelope = (method: string, fields: Record) => ({ + sessionId: SESSION, + clientOperationId: `${Date.now()}-${(++operation).toString(16).padStart(32, '0')}`, + expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + }) + const setOption = (key: string, value: string | boolean) => { + if (typeof value !== 'string') { + throw new Error('This flow picks select options.') + } + const write = host + .setOption(CALLER, { + envelope: envelope('agentSession.setOption', { key, value }), + key, + value + }) + .then((result) => { + expect(result).toMatchObject({ ok: true }) + return { snapshot: [] } + }) + picked.push(write) + return write + } + const seed = structuredAgentSessionSeedCatalog('claude') + if (!seed) { + throw new Error('Claude options are not registered.') + } + const snapshot = structuredAgentSessionOptionSnapshot( + applyStructuredAgentSessionOptions(createStructuredAgentSessionOptionState('claude'), seed, { + models: ['opus', 'sonnet'].map((id) => ({ + id, + label: id, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + })), + current: { model: 'opus', effort: 'high' } + }) + ) + render( + createElement( + TooltipProvider, + {}, + createElement(NativeChatSessionOptionPickers, { + surface: { + getSnapshot: () => snapshot, + setOption, + invokeAction: async () => ({ snapshot }), + subscribe: () => () => {} + }, + snapshot, + isWorking: false + }) + ) + ) + fireEvent.click(screen.getByRole('button', { name: 'Model opus' })) + fireEvent.click(await screen.findByRole('option', { name: 'sonnet' })) + const options = await screen.findByRole('button', { name: /effort High/i }) + await waitFor(() => expect(options.getAttribute('disabled')).toBeNull()) + fireEvent.pointerDown(options, { button: 0, ctrlKey: false, pointerType: 'mouse' }) + fireEvent.click(await screen.findByRole('menuitemradio', { name: 'Low' })) + const body = hostTestMessage('reply using these choices') + await expect( + host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).resolves.toMatchObject({ ok: true }) + child.answerInit() + await Promise.all(picked) + await vi.waitFor(async () => { + const reply = (await host.journalSnapshot(SESSION)).items.find( + (item) => item.body.kind === 'message' && item.body.role === 'assistant' + ) + expect(reply?.body).toMatchObject({ + blocks: [{ type: 'text', text: 'low' }] + }) + }) + expect(firstReply).toEqual({ model: 'sonnet', effort: 'low' }) + delayed.mockRestore() + } finally { + await claude.dispose() + await Promise.allSettled(picked) + } +}) 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 9fc2255514b2..bc02eefb2cba 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 { startsWhenPublished } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support' 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' @@ -42,24 +43,34 @@ function openHost(): void { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire: async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW - } - }), - closeSession, - releaseAcquisition: async () => true, - dispatch: async () => ({ state: 'rejected', reason: 'unused' }), - cancelTurn: async () => ({ cancelled: false }), - answerPrompt: async () => undefined, - setOption: async () => undefined - }, + // A ready provider proves its start as soon as it is published. + adapter: startsWhenPublished( + { + acquire: async ({ fence, spawnToken }) => ({ + providerChildPhase: 'starting' as const, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + closeSession, + releaseAcquisition: async () => true, + dispatch: async () => ({ state: 'rejected', reason: 'unused' }), + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => undefined, + setOption: async () => undefined + }, + () => host + ), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', @@ -97,7 +108,10 @@ function serveDesktopRuntime(): void { try { return { ok: true, result: host.handoffStatus(params.sessionId ?? '') } } catch (error) { - return { ok: false, error: { code: String(error), message: String(error) } } + return { + ok: false, + error: { code: String(error), message: String(error) } + } } } throw new Error(`Unexpected runtime method: ${method}`)