diff --git a/src/main/claude/claude-structured-init-deadline.ts b/src/main/claude/claude-structured-init-deadline.ts index f8531985be6..9dfa66a8ffe 100644 --- a/src/main/claude/claude-structured-init-deadline.ts +++ b/src/main/claude/claude-structured-init-deadline.ts @@ -1,4 +1,7 @@ import type { ClaudeInitObservation } from './claude-structured-init-proof' +import { claudeInitializationAuthError } from './claude-structured-init-proof' +import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' export type ClaudeInitDeadline = { promise: Promise @@ -8,6 +11,39 @@ export type ClaudeInitDeadline = { clear: () => void } +export function claudeInitTimeoutError( + sessionId: string, + timeoutMs: number +): AgentSessionAcquisitionRefusal { + return new AgentSessionAcquisitionRefusal( + `Claude did not finish starting session ${sessionId} within ${Math.ceil(timeoutMs / 1000)} seconds. Verify the selected Claude account is signed in and CLAUDE_CONFIG_DIR contains valid credentials, then retry; no SessionStart or system/init proof arrived.` + ) +} + +export async function requestClaudeInitialization( + connection: ClaudeStreamJsonConnection, + sessionId: string, + timeoutMs: number +): Promise { + try { + const result = await connection.request( + 'initialize', + { supportedDialogKinds: [] }, + { timeoutMs } + ) + const authError = claudeInitializationAuthError(result) + if (authError) { + throw authError + } + return result + } catch (error) { + if (error instanceof Error && error.message === 'claude initialize request timed out') { + throw claudeInitTimeoutError(sessionId, timeoutMs) + } + throw error + } +} + export function createClaudeInitDeadline(sessionId: string, timeoutMs: number): ClaudeInitDeadline { let resolve = (_init: ClaudeInitObservation): void => {} let reject = (_error: Error): void => {} @@ -23,10 +59,7 @@ export function createClaudeInitDeadline(sessionId: string, timeoutMs: number): resolve, reject, start: () => { - timer = setTimeout( - () => reject(new Error(`claude session ${sessionId} did not emit system/init`)), - timeoutMs - ) + timer = setTimeout(() => reject(claudeInitTimeoutError(sessionId, timeoutMs)), timeoutMs) timer.unref?.() }, clear: () => { diff --git a/src/main/claude/claude-structured-init-proof.ts b/src/main/claude/claude-structured-init-proof.ts index f426c0ce346..ca9a355264d 100644 --- a/src/main/claude/claude-structured-init-proof.ts +++ b/src/main/claude/claude-structured-init-proof.ts @@ -1,5 +1,6 @@ import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution' import type { ClaudeAuthDiagnostic } from './claude-structured-session-state' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' export type ClaudeInitObservation = { providerSessionId: string @@ -17,14 +18,20 @@ export function readClaudeFrameString(source: Record, key: stri } export function readClaudeInit(message: Record): ClaudeInitObservation | null { - if (message.type !== 'system' || message.subtype !== 'init') { + const hookName = readClaudeFrameString(message, 'hook_name') + const isInit = message.type === 'system' && message.subtype === 'init' + const isSessionStart = + message.type === 'system' && + (message.subtype === 'hook_started' || message.subtype === 'hook_response') && + hookName?.startsWith('SessionStart:') === true + if (!isInit && !isSessionStart) { return null } const providerSessionId = readClaudeFrameString(message, 'session_id') return providerSessionId ? { providerSessionId, - uuid: readClaudeFrameString(message, 'uuid'), + uuid: isInit ? readClaudeFrameString(message, 'uuid') : null, message } : null @@ -36,6 +43,18 @@ export function readClaudeModels(initialization: unknown): unknown[] { : [] } +export function claudeInitializationAuthError( + initialization: unknown +): AgentSessionAcquisitionRefusal | null { + const account = + isRecord(initialization) && isRecord(initialization.account) ? initialization.account : null + return readClaudeFrameString(account ?? {}, 'tokenSource') === 'none' + ? new AgentSessionAcquisitionRefusal( + 'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.' + ) + : null +} + export function claudeAuthDiagnostic( init: ClaudeInitObservation, settings: unknown diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 3f79ba1066c..b8f7c831623 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -4,6 +4,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { CLAUDE_DEFAULT_SETTING_SOURCES, + CLAUDE_STRUCTURED_BASE_ARGS, claudeSessionIdForOrcaSession, createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' @@ -58,6 +59,7 @@ describe('claude structured launch resolution', () => { expect(first.args).toContain('stdio') expect(first.args).toContain('--setting-sources') expect(first.args).toContain(CLAUDE_DEFAULT_SETTING_SOURCES.join(',')) + expect(CLAUDE_STRUCTURED_BASE_ARGS).toContain('--verbose') }) it('resumes the session and leaf at the durable chain head', async () => { diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index bd33dc7f60f..4a67c7537d9 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -8,7 +8,7 @@ import { getSpawnArgsForWindows } from '../win32-utils' export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const -const BASE_ARGS = [ +export const CLAUDE_STRUCTURED_BASE_ARGS = [ '-p', '--input-format', 'stream-json', @@ -81,7 +81,10 @@ export function createClaudeStructuredLaunchResolver( ? ['--resume', providerSessionId] : ['--session-id', providerSessionId] const command = (deps.resolveCommand ?? resolveClaudeCommand)() - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, [...BASE_ARGS, ...providerArgs]) + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, [ + ...CLAUDE_STRUCTURED_BASE_ARGS, + ...providerArgs + ]) return { command: spawnCmd, args: spawnArgs, diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts new file mode 100644 index 00000000000..c92f7ec8171 --- /dev/null +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -0,0 +1,108 @@ +import { spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { resolveClaudeCommand } from '../codex-cli/command' +import { getSpawnArgsForWindows } from '../win32-utils' +import { CLAUDE_STRUCTURED_BASE_ARGS } from './claude-structured-launch-resolution' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' + +const command = resolveClaudeCommand() +const versionLaunch = getSpawnArgsForWindows(command, ['--version']) +const realClaudeAvailable = + spawnSync(versionLaunch.spawnCmd, versionLaunch.spawnArgs, { + stdio: 'ignore', + windowsHide: true, + timeout: 5_000 + }).status === 0 + +function realAdapter( + providerSessionId: string, + claudeConfigDir: string, + events: ClaudeStructuredSessionEvent[] = [] +): ClaudeStructuredSessionAdapter { + const launch = getSpawnArgsForWindows(command, [ + ...CLAUDE_STRUCTURED_BASE_ARGS, + '--session-id', + providerSessionId + ]) + return new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: launch.spawnCmd, + args: launch.spawnArgs, + cwd: process.cwd(), + claudeConfigDir, + providerSessionId, + resumeLeafUuid: null, + resumed: false + }), + onEvent: (event) => events.push(event), + readProcessStartTime: async () => 1, + now: () => 2, + initTimeoutMs: 5_000 + }) +} + +function identity(providerSessionId: string): AgentSessionJournalIdentity { + return { + sessionId: 'real-cli-handshake', + workspaceId: 'real-cli-workspace', + hostId: 'local', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: providerSessionId, leafUuid: null } + } +} + +describe.skipIf(!realClaudeAvailable)('Claude structured real CLI handshake', () => { + it('proves a pre-minted session before the first user message', async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = realAdapter(providerSessionId, claudeConfigDir, events) + + try { + const acquisition = await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli' + }) + const observedSubtypes = events.flatMap((event) => + event.type === 'message' ? [event.message.subtype] : [] + ) + + expect(acquisition.link.handle).toMatchObject({ + provider: 'claude', + sessionId: providerSessionId, + leafUuid: expect.any(String) + }) + expect(observedSubtypes).toContain('hook_started') + } finally { + await adapter.closeAll() + } + }, 10_000) + + it('turns a real silent unauthenticated startup into sign-in guidance', async () => { + const claudeConfigDir = await mkdtemp(join(tmpdir(), 'orca-claude-no-auth-')) + const providerSessionId = randomUUID() + const adapter = realAdapter(providerSessionId, claudeConfigDir) + + try { + await expect( + adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-no-auth' + }) + ).rejects.toThrow(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s) + } finally { + await adapter.closeAll() + await rm(claudeConfigDir, { recursive: true, force: true }) + } + }, 10_000) +}) diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index d7b191378ed..347d358cde7 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -3,6 +3,7 @@ import type { AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection, ClaudeStreamJsonConnectionHandlers, @@ -13,6 +14,7 @@ import { ClaudeControlRequestError } from './claude-stream-json-connection' import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity' import { encodeClaudeQuestionOptionId } from './claude-structured-prompt-replies' import { + CLAUDE_STRUCTURED_INIT_TIMEOUT_MS, ClaudeStructuredSessionAdapter, type ClaudeStructuredLaunch, type ClaudeStructuredSessionEvent @@ -54,6 +56,8 @@ function fakeClaude( initUuid?: string initModel?: string initEffort?: string + initProof?: 'init' | 'session-start' | 'none' + initAccount?: unknown exitBeforeInit?: string settings?: unknown replayUuid?: string | null @@ -83,16 +87,29 @@ function fakeClaude( handlers.onExit?.(new Error(options.exitBeforeInit)) return { models: [] } } - handlers.onMessage?.({ - type: 'system', - subtype: 'init', - session_id: options.initSessionId ?? PROVIDER_SESSION_ID, - uuid: options.initUuid ?? 'init-uuid', - model: options.initModel ?? 'claude-sonnet-5', - effortLevel: options.initEffort ?? 'high', - apiKeySource: 'none' - }) - return { models: [{ value: 'claude-sonnet', displayName: 'Sonnet' }] } + if (options.initProof === 'session-start') { + handlers.onMessage?.({ + type: 'system', + subtype: 'hook_started', + hook_name: 'SessionStart:startup', + session_id: options.initSessionId ?? PROVIDER_SESSION_ID, + uuid: options.initUuid ?? 'init-uuid' + }) + } else if (options.initProof !== 'none') { + handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: options.initSessionId ?? PROVIDER_SESSION_ID, + uuid: options.initUuid ?? 'init-uuid', + model: options.initModel ?? 'claude-sonnet-5', + effortLevel: options.initEffort ?? 'high', + apiKeySource: 'none' + }) + } + return { + models: [{ value: 'claude-sonnet', displayName: 'Sonnet' }], + ...(options.initAccount === undefined ? {} : { account: options.initAccount }) + } } if (subtype === 'get_settings') { return options.settings ?? { env: {} } @@ -130,7 +147,8 @@ function adapterFor( claude: ReturnType, launch: Partial = {}, events: ClaudeStructuredSessionEvent[] = [], - persistedHandles: unknown[] = [] + persistedHandles: unknown[] = [], + initTimeoutMs?: number ): ClaudeStructuredSessionAdapter { return new ClaudeStructuredSessionAdapter({ resolveLaunch: async () => ({ @@ -147,6 +165,7 @@ function adapterFor( openConnection: claude.openConnection, readProcessStartTime: async () => 1_700_000_000_000, now: () => 1_700_000_000_500, + ...(initTimeoutMs === undefined ? {} : { initTimeoutMs }), dispatchAckTimeoutMs: 10, persistHandle: async (handle) => { persistedHandles.push(handle) @@ -165,6 +184,10 @@ async function acquired( } describe('ClaudeStructuredSessionAdapter.acquire', () => { + it('finishes its startup deadline before the paired mobile request deadline', () => { + expect(CLAUDE_STRUCTURED_INIT_TIMEOUT_MS).toBeLessThan(30_000) + }) + it('pins the account, proves init, and reports the process and chain leaf', async () => { const claude = fakeClaude() const events: ClaudeStructuredSessionEvent[] = [] @@ -224,6 +247,28 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { }) }) + it('accepts SessionStart as the pre-turn session proof from the real CLI protocol', async () => { + const claude = fakeClaude({ initProof: 'session-start', initUuid: 'session-start-uuid' }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + + const acquisition = await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9' + }) + + expect(acquisition.link.handle).toEqual({ + provider: 'claude', + sessionId: PROVIDER_SESSION_ID, + leafUuid: 'session-start-uuid' + }) + expect(events[0]).toMatchObject({ + type: 'message', + message: { subtype: 'hook_started', hook_name: 'SessionStart:startup' } + }) + }) + it('records only non-secret effective auth-lane diagnostics', async () => { const claude = fakeClaude({ settings: { @@ -287,6 +332,38 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { ).rejects.toThrow('Claude login required') expect(claude.connections[0].closeCount).toBe(1) }) + + it('closes a silent unauthenticated startup with actionable account guidance', async () => { + const claude = fakeClaude({ initProof: 'none' }) + const adapter = adapterFor(claude, {}, [], [], 20) + + const error = await adapter + .acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + .catch((cause: unknown) => cause) + + expect(error).toBeInstanceOf(AgentSessionAcquisitionRefusal) + expect(error).toMatchObject({ + message: expect.stringMatching(/selected Claude account is signed in.*CLAUDE_CONFIG_DIR/s) + }) + expect(claude.connections[0].calls[0]).toEqual({ + subtype: 'initialize', + params: { supportedDialogKinds: [] } + }) + expect(claude.connections[0].closeCount).toBe(1) + }) + + it('refuses an unauthenticated initialize response even when SessionStart runs', async () => { + const claude = fakeClaude({ + initProof: 'session-start', + initAccount: { apiProvider: 'firstParty', tokenSource: 'none' } + }) + const adapter = adapterFor(claude) + + await expect( + adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toThrow(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s) + expect(claude.connections[0].closeCount).toBe(1) + }) }) describe('ClaudeStructuredSessionAdapter turns and controls', () => { diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index d4df1d9b6f3..3c1caa4d184 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -17,7 +17,10 @@ import { readClaudeInit, readClaudeModels } from './claude-structured-init-proof' -import { createClaudeInitDeadline } from './claude-structured-init-deadline' +import { + createClaudeInitDeadline, + requestClaudeInitialization +} from './claude-structured-init-deadline' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity' import { setClaudeStructuredOption } from './claude-structured-options' @@ -45,7 +48,7 @@ export type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' -const INIT_TIMEOUT_MS = 30_000 +export const CLAUDE_STRUCTURED_INIT_TIMEOUT_MS = 10_000 const DISPATCH_ACK_TIMEOUT_MS = 10_000 export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAdapter { @@ -68,7 +71,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda const { previous, attempt } = this.acquisitions.start(sessionId, prompts) let liveSession: ClaudeSession | null = null let observedLeafUuid: string | null = null - const initDeadline = createClaudeInitDeadline(sessionId, INIT_TIMEOUT_MS) + const initTimeoutMs = this.deps.initTimeoutMs ?? CLAUDE_STRUCTURED_INIT_TIMEOUT_MS + const initDeadline = createClaudeInitDeadline(sessionId, initTimeoutMs) const onMessage = (message: Record): void => { const init = readClaudeInit(message) @@ -141,11 +145,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda this.acquisitions.assertCurrent(sessionId, attempt) initDeadline.start() const [initialization, init] = await Promise.all([ - connection.request( - 'initialize', - { supportedDialogKinds: [] }, - { timeoutMs: this.deps.requestTimeoutMs } - ), + requestClaudeInitialization(connection, sessionId, initTimeoutMs), initDeadline.promise ]) const models = readClaudeModels(initialization) diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index a94a03917af..73876f87132 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -41,6 +41,7 @@ export type ClaudeStructuredSessionAdapterDeps = { mintLinkId?: () => string now?: () => number requestTimeoutMs?: number + initTimeoutMs?: number dispatchAckTimeoutMs?: number persistHandle?: (input: { sessionId: string 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 f40848f6004..8800a5716ce 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 @@ -16,9 +16,22 @@ import type { AgentSessionExecutionLocation, AgentSessionProcessIdentity } from '../../../shared/agent-session-record' -import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' +import type { + AgentSessionOptionsResult, + AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +export class AgentSessionAcquisitionRefusal extends Error { + constructor( + message: string, + readonly code: AgentSessionWireRefusalCode = 'agent_session_operation_invalid' + ) { + super(message) + this.name = 'AgentSessionAcquisitionRefusal' + } +} + /** What a reservation turns into once something is actually running under it: * the process the host can probe, and the provider handle it was minted with. */ export type AgentSessionAcquisition = { 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 b75990348a8..6da575e0f60 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 @@ -24,7 +24,9 @@ import { } from './structured-agent-session-attach' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' export type AttachFlowInput = { store: AgentSessionRecordStore @@ -72,10 +74,29 @@ export async function performAttach( ) record = reserved.record replayed = reserved.disposition === 'replayed' + if (replayed && reserved.operationRow.outcome.status === 'failed') { + const replay = resolveAgentSessionReplayOutcome({ + operationId: params.envelope.clientOperationId, + outcome: reserved.operationRow.outcome, + reconstruct: () => null + }) + if (replay.decision === 'refuse') { + return { ok: false, refusal: replay.refusal } + } + } if (!agentSessionLeaseAdmitsWriter(record.lease)) { record = await acquireOwner(input, record) } } catch (error) { + if (error instanceof AgentSessionAcquisitionRefusal) { + const refusal = { code: error.code, message: error.message } + await store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'failed', code: refusal.code, message: refusal.message } + }) + return { ok: false, refusal } + } return { ok: false, refusal: classifyStoreFailure(error, store.getRecord(sessionId)?.lease.runtimeFence ?? null) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts index 9417b5d7fd1..f1a155485d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts @@ -25,7 +25,7 @@ export function resolveAgentSessionReplayOutcome(input: { decision: 'refuse', refusal: { code, - message: `Operation ${operationId} was already refused: ${outcome.code}.` + message: outcome.message ?? `Operation ${operationId} was already refused: ${outcome.code}.` } } } diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 4bef47657b6..7811359c947 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -44,6 +44,7 @@ type FakeClaudeConnection = Omit & { function fakeClaude() { const connections: FakeClaudeConnection[] = [] + let initializeAccount: unknown const openConnection = (async (launch, handlers = {}) => { const connection: FakeClaudeConnection = { launch, @@ -64,7 +65,10 @@ function fakeClaude() { model: 'claude-sonnet-5', apiKeySource: 'none' }) - return { models: [{ value: 'sonnet', displayName: 'Sonnet' }] } + return { + models: [{ value: 'sonnet', displayName: 'Sonnet' }], + ...(initializeAccount === undefined ? {} : { account: initializeAccount }) + } } return subtype === 'get_settings' ? { env: {} } : {} }, @@ -92,7 +96,14 @@ function fakeClaude() { } return connection } - return { connections, openConnection, live } + return { + connections, + openConnection, + live, + setInitializeAccount: (account: unknown) => { + initializeAccount = account + } + } } let operations = 0 @@ -267,6 +278,27 @@ afterEach(async () => { }) describe('a structured Claude session over agentSession.*', () => { + it('durably returns actionable sign-in guidance when initialization has no credentials', async () => { + claude.setInitializeAccount({ apiProvider: 'firstParty', tokenSource: 'none' }) + const params = createIntentParams() + + const first = await call('agentSession.create', params) + const retry = await call('agentSession.create', params) + + expect(first).toMatchObject({ + ok: true, + result: { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: expect.stringMatching(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s) + } + } + }) + expect((retry as { result: unknown }).result).toEqual((first as { result: unknown }).result) + expect(claude.connections).toHaveLength(1) + }) + it('creates, sends, streams, approves, interrupts, and resumes from the chain head', async () => { const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) expect(claude.live().launch.args).toContain('--session-id') diff --git a/src/shared/agent-session-operation-ledger.ts b/src/shared/agent-session-operation-ledger.ts index 864c2c66485..f53c6665794 100644 --- a/src/shared/agent-session-operation-ledger.ts +++ b/src/shared/agent-session-operation-ledger.ts @@ -20,7 +20,7 @@ export const AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT = 4_096 export type AgentSessionOperationOutcome = | { status: 'pending' } | { status: 'succeeded'; sessionId: string } - | { status: 'failed'; code: string } + | { status: 'failed'; code: string; message?: string } /** The effect may or may not have happened; replay this answer instead of spawning again. */ | { status: 'unknown' }