mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
fix: prove real Claude structured startup
This commit is contained in:
@@ -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<ClaudeInitObservation>
|
||||
@@ -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<unknown> {
|
||||
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: () => {
|
||||
|
||||
@@ -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<string, unknown>, key: stri
|
||||
}
|
||||
|
||||
export function readClaudeInit(message: Record<string, unknown>): 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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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<typeof fakeClaude>,
|
||||
launch: Partial<ClaudeStructuredLaunch> = {},
|
||||
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', () => {
|
||||
|
||||
@@ -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<string, unknown>): 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)
|
||||
|
||||
@@ -41,6 +41,7 @@ export type ClaudeStructuredSessionAdapterDeps = {
|
||||
mintLinkId?: () => string
|
||||
now?: () => number
|
||||
requestTimeoutMs?: number
|
||||
initTimeoutMs?: number
|
||||
dispatchAckTimeoutMs?: number
|
||||
persistHandle?: (input: {
|
||||
sessionId: string
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -25,7 +25,7 @@ export function resolveAgentSessionReplayOutcome<TValue>(input: {
|
||||
decision: 'refuse',
|
||||
refusal: {
|
||||
code,
|
||||
message: `Operation ${operationId} was already refused: ${outcome.code}.`
|
||||
message: outcome.message ?? `Operation ${operationId} was already refused: ${outcome.code}.`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ type FakeClaudeConnection = Omit<ClaudeStreamJsonConnection, 'closed'> & {
|
||||
|
||||
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')
|
||||
|
||||
@@ -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' }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user