Skip to content
2 changes: 2 additions & 0 deletions config/scripts/vitest-sqlite-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-startup-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-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',
Expand Down
8 changes: 7 additions & 1 deletion src/main/acp/acp-agent-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export type AcpAgentConnectionOptions = Omit<AcpSessionRuntimeOptions, 'peer'> &
peer?: Omit<AcpPeerOptions, 'closeOnInputEnd'>
/** Process exit evidence, including expected closes and processless spawn failures. */
onExit?: (error: Error, context: { expected: boolean; exit: ProviderProcessExit }) => void
/** Any stdout or stderr chunk from the child. */
onOutput?: () => void
}

/** The execution host owns the child and protocol lifetime; the adapter owns turns and Stop. */
Expand All @@ -37,7 +39,11 @@ export class AcpAgentConnection extends AcpSessionRuntime {
) {
// Validate before spawning so invalid limits cannot leave an unowned child.
const peer = resolveAcpPeerOptions({ ...options.peer, closeOnInputEnd: false })
const managed = spawnManagedProviderProcess(launch, { spawnImpl, site: 'acp-agent-teardown' })
const managed = spawnManagedProviderProcess(launch, {
spawnImpl,
site: 'acp-agent-teardown',
...(options.onOutput ? { onOutput: options.onOutput } : {})
})
const lifecycle: { closing: boolean; error?: Error } = { closing: false }
const diagnose = (message: string): void => {
try {
Expand Down
1 change: 1 addition & 0 deletions src/main/acp/acp-structured-acquire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export async function acquireAcpStructuredSession(input: {
},
{
clientInfo: { name: 'orca', version: '1' },
...(acquire.onOutput ? { onOutput: acquire.onOutput } : {}),
onPermission: (request, context) => {
if (!session?.turns.acceptsRequests) {
// No prompt of Orca's runs (a turn the agent began itself included), or a Stop or steer
Expand Down
6 changes: 4 additions & 2 deletions src/main/claude/claude-agent-sdk-process-spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ function definedEnv(env: Record<string, string | undefined>): Record<string, str
*/
export function createClaudeCodeProcessSpawn(
spawnImpl: typeof spawnProcess = spawnProcess,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
onOutput?: () => void
): ClaudeCodeProcessSpawn {
let managed: ManagedProviderProcess | null = null
return {
Expand All @@ -64,7 +65,8 @@ export function createClaudeCodeProcessSpawn(
inheritedEnv: definedEnv(options.env),
site: 'claude-stream-json-teardown',
policy: (supervised) => claudeChildClosePolicy(supervised, platform),
acceptClose: claudeChildCloseProven
acceptClose: claudeChildCloseProven,
...(onOutput ? { onOutput } : {})
}
)
// The SDK drains stderr only for its own local spawn; the managed process drains it here.
Expand Down
4 changes: 3 additions & 1 deletion src/main/claude/claude-stream-json-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export type ClaudeStreamJsonConnectionHandlers = {
/** The root process exited, reported once. `expected`: a close had begun, so it is that close's
* end, even one that ran out of its own escalation first and came back unproven. */
onExit?: (error: Error, exit?: { expected: boolean }) => void
/** Any stdout or stderr chunk from the child. */
onOutput?: () => void
}

/**
Expand Down Expand Up @@ -127,7 +129,7 @@ export async function openClaudeStreamJsonConnection(
queryImpl?: typeof ClaudeAgentSdk.query
): Promise<ClaudeStreamJsonConnection> {
const { query } = await loadClaudeAgentSdk()
const spawner = createClaudeCodeProcessSpawn(spawnImpl)
const spawner = createClaudeCodeProcessSpawn(spawnImpl, process.platform, handlers.onOutput)
const inbox = createClaudeUserMessageQueue()
const session = (queryImpl ?? query)({
prompt: inbox.messages,
Expand Down
9 changes: 7 additions & 2 deletions src/main/claude/claude-structured-applied-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,9 @@ describe('Claude model before the first turn', () => {
const events: ClaudeStructuredSessionEvent[] = []
await acquired(startedWithoutATurn(SETTINGS.settingsHaiku), {}, events)

const started = events.find((event) => event.type === 'started')
expect(started).toMatchObject({ reportedOptions: { model: 'haiku' } })
// The settings read follows `started`; what it shows is persisted as the later report.
const reported = events.find((event) => event.type === 'options-reported')
expect(reported).toMatchObject({ reportedOptions: { model: 'haiku' } })
})

it('shows the applied effort but never persists it as the session options', async () => {
Expand All @@ -139,6 +140,10 @@ describe('Claude model before the first turn', () => {
expect(started?.type === 'started' ? started.reportedOptions : null).not.toHaveProperty(
'effort'
)
const reported = events.find((event) => event.type === 'options-reported')
expect(
reported?.type === 'options-reported' ? reported.reportedOptions : null
).not.toHaveProperty('effort')
const { current } = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
expect(current.effort).toBe('medium')
})
Expand Down
55 changes: 55 additions & 0 deletions src/main/claude/claude-structured-init-proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,58 @@ export function claudeAuthDiagnostic(
settingSources: CLAUDE_DEFAULT_SETTING_SOURCES
}
}

/** The CLI's own frame naming the session it runs (system/init or a SessionStart hook). Only a
* SessionStart hook sends one before the first turn, so startup takes it when it came and never
* waits for it. */
export type ClaudeInitProof = {
promise: Promise<ClaudeInitObservation>
resolve: (init: ClaudeInitObservation) => void
reject: (error: Error) => void
/** A frame named another provider session. */
refuse: () => void
/** The proof seen so far, or null; throws when it was refused or the child failed first. */
seen: () => ClaudeInitObservation | null
/** Set once startup has read the proof: a later refusal ends the session. */
onRefusal: ((error: Error) => void) | null
}

export function createClaudeInitProof(): ClaudeInitProof {
let resolvePromise = (_init: ClaudeInitObservation): void => {}
let rejectPromise = (_error: Error): void => {}
const promise = new Promise<ClaudeInitObservation>((resolve, reject) => {
resolvePromise = resolve
rejectPromise = reject
})
void promise.catch(() => {})
let outcome: { init: ClaudeInitObservation } | { error: Error } | null = null
const reject = (error: Error): void => {
outcome ??= { error }
rejectPromise(error)
}
const proof: ClaudeInitProof = {
promise,
resolve: (init) => {
outcome ??= { init }
resolvePromise(init)
},
reject,
refuse: () => {
// A session already proven by its own frame keeps that proof.
if (outcome && 'init' in outcome) {
return
}
const error = new Error('claude provider session expected')
reject(error)
proof.onRefusal?.(error)
},
seen: () => {
if (outcome && 'error' in outcome) {
throw outcome.error
}
return outcome?.init ?? null
},
onRefusal: null
}
return proof
}
61 changes: 61 additions & 0 deletions src/main/claude/claude-structured-real-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,67 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => {
90_000
)

// The host hands a Claude child nothing until `started`, which now 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',
async () => {
const providerSessionId = randomUUID()
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude')
const events: ClaudeStructuredSessionEvent[] = []
const adapter = realAdapter(
providerSessionId,
claudeConfigDir,
events,
process.cwd(),
undefined,
realClaudeLaunchHome().env,
false
)
const until = async (done: () => boolean, timeoutMs: number): Promise<boolean> => {
const deadline = Date.now() + timeoutMs
while (!done() && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 20))
}
return done()
}
try {
const acquireAt = Date.now()
await adapter.acquire({
identity: identity(providerSessionId),
fence: 1,
spawnToken: 'real-cli-started-timing'
})
const publishedAt = Date.now()
expect(await until(() => events.some((event) => event.type === 'started'), 30_000)).toBe(
true
)
const startedAt = Date.now()
const before = events.length
await adapter.dispatch({
sessionId: 'real-cli-handshake',
clientMessageId: 'real-cli-started-timing-1',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Say ok' }] },
fence: 1
})
expect(
await until(() => events.slice(before).some((event) => event.type === 'message'), 60_000)
).toBe(true)
const firstOutputAt = Date.now()
// 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, ` +
`dispatch->first output ${firstOutputAt - startedAt} ms`
)
} finally {
await adapter.closeAll()
}
},
120_000
)

// The contract a chat's first message depends on: saved options ride the launch, and the
// message is written as soon as the child is published, before the CLI answers initialize.
it.skipIf(!realClaudeAuthenticated)(
Expand Down
11 changes: 8 additions & 3 deletions src/main/claude/claude-structured-session-acquisition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution'
import { claudeSessionStateEndsTurn } from './claude-session-state-turn-over'
import { settleClaudeTurnEndWaiters } from './claude-request-end-wait'
import {
createClaudeInitProof,
readClaudeCapabilities,
readClaudeFrameString,
readClaudeInit
Expand All @@ -19,11 +20,11 @@ import { adoptClaudeStructuredSpawnOptions } from './claude-structured-spawn-opt
import { createClaudeSessionJournalTranslator } from './claude-structured-journal-translation'
import { observeClaudeFastModeFacts } from './claude-structured-session-options'
import {
createClaudeInitProof,
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,
Expand Down Expand Up @@ -183,6 +184,7 @@ export async function acquireClaudeSession({
onMessage,
canUseTool,
onUserDialog,
...(input.onOutput ? { onOutput: input.onOutput } : {}),
onFault: (error) => {
childEnded ??= error
initProof.reject(error)
Expand Down Expand Up @@ -276,8 +278,12 @@ export async function acquireClaudeSession({
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,
Expand All @@ -294,8 +300,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`
// says otherwise; it already takes input.
// The start reads its facts only after publish, so the child is `starting` until `started`.
return { ...publication.acquisition, providerChildPhase: 'starting' }
} catch (error) {
unbindReadingControl?.()
Expand Down
2 changes: 1 addition & 1 deletion src/main/claude/claude-structured-session-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => {
).toEqual([])
// Left out, not refused: the record takes the provider's own Fast, as before.
expect(adapter.readOptionRestoreFailures('session-1')).toEqual([])
expect(events.find((event) => event.type === 'started')).toMatchObject({
expect(events.find((event) => event.type === 'options-reported')).toMatchObject({
reportedOptions: { fastMode: false }
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection'
import type { ClaudeJournalTranslator } from './claude-journal-translator-contract'
import type { ClaudeInitProof } from './claude-structured-session-startup'
import type { ClaudeInitProof } from './claude-structured-init-proof'
import type {
ClaudeAcquisitionAttempt,
ClaudeAcquireCallbacks
Expand Down
5 changes: 2 additions & 3 deletions src/main/claude/claude-structured-session-startup-state.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// 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. Messages are written to it at once
// (the CLI queues them behind its start); only an option write, a control request initialize must
// answer first, waits for startup to land.
// 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'
Expand Down
36 changes: 35 additions & 1 deletion src/main/claude/claude-structured-session-startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,38 @@ describe('Claude structured session publishes before the CLI answers initialize'
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
)
)
})
const { adapter, events } = startingAdapter(claude)
await adapter.acquire(ACQUIRE)

await vi.advanceTimersByTimeAsync(SLOW_INIT_MS)

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)
Expand All @@ -87,7 +119,9 @@ describe('Claude structured session publishes before the CLI answers initialize'
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: []
restoreSkippedOptions: [],
// No host revision handed in: nothing has moved it.
optionRevision: 0
})
expect(claude.connections[0].calls.map(({ subtype }) => subtype)).toEqual([
'initialize',
Expand Down
Loading
Loading