diff --git a/packages/claude-sdk-adapter/README.md b/packages/claude-sdk-adapter/README.md index 8c562ed57..aeaf289e4 100644 --- a/packages/claude-sdk-adapter/README.md +++ b/packages/claude-sdk-adapter/README.md @@ -34,7 +34,7 @@ The Harness environment is closed: the Session home's native directories, the ga ### Executor preparation and Turns -The private bridge accepts `executor_prepare` without model input. It freezes validated configuration and resume identity, checks required history, and retains one native process and SDK Query across Turns. Preparation requires initialization and acknowledgement of required hooks while the input iterator remains empty. An `executor_ready` receipt permits later `turn_start` messages containing only Turn identity and ordered input; configuration replacement and concurrent starts are rejected. Every Turn event carries its originating `turn_id`. Native Session identity and actual tool inventory are checked before `input_ready`. +The private bridge accepts `executor_prepare` without model input. It freezes validated configuration and resume identity, checks required history, and retains one native process and SDK Query across Turns. Preparation requires initialization and acknowledgement of required hooks while the input iterator remains empty. The pinned SDK owns the initialization deadline through its default `startup` behavior. An `executor_ready` receipt permits later `turn_start` messages containing only Turn identity and ordered input; configuration replacement and concurrent starts are rejected. Every Turn event carries its originating `turn_id`. Native Session identity and actual tool inventory are checked before `input_ready`. Each Turn ends with a result/error and `turn_settled`, independently of process exit. The outer input iterator remains open for later Turns. `turn_cancel` invokes the native interrupt control for that exact Turn. Unconfirmed input, native child work or queue state invalidates the Executor and requires close before replacement. EOF, owner signals and invalid control input close owned resources. Preparation may write native metadata and perform startup traffic; readiness does not prove provider authentication, complete sandbox health or placement authorization. diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts index 003758a45..5a850374b 100644 --- a/packages/claude-sdk-adapter/src/adapter.ts +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -125,7 +125,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: if(canUseTool) options.canUseTool=(...args)=>turns.track(()=>canUseTool(...args)); } if (request.type === "prepare" || request.type === "executor_prepare") { - warm = await startup({ options, initializeTimeoutMs: 15000 }); + warm = await startup({ options }); stream = warm.query(turns ?? inputs); const initialized = await stream.initializationResult(); const hooksRequested = Object.values(options.hooks ?? {}).some(matchers => matchers?.some(matcher => matcher.hooks.length > 0)); @@ -151,7 +151,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: } else await emit({ type: "prepared" }); } else if (profile) { if (inputs.hasInput) throw new Error("MCP input released before initialization"); - warm = await startup({ options, initializeTimeoutMs: 15000 }); + warm = await startup({ options }); stream = warm.query(turns ?? inputs); const initialized = await stream.initializationResult(); if (initialized.hooks_applied !== true || children.length !== 1) throw new Error("MCP initialization unavailable"); diff --git a/packages/claude-sdk-adapter/tests/preparation.test.mjs b/packages/claude-sdk-adapter/tests/preparation.test.mjs index fb3e4fdc0..3b6c6c633 100644 --- a/packages/claude-sdk-adapter/tests/preparation.test.mjs +++ b/packages/claude-sdk-adapter/tests/preparation.test.mjs @@ -17,8 +17,7 @@ registerHooks({ resolve(specifier, context, next) { if (specifier === "@anthropic-ai/claude-agent-sdk") return {url:"data:text/javascript,"+encodeURIComponent(sdk),shortCircuit:true}; return next(specifier,context); }}); -globalThis.startupFixture = async ({options, initializeTimeoutMs}) => { - assert.equal(initializeTimeoutMs,15000); +globalThis.startupFixture = async ({options}) => { assert.equal(options.model,"fixed-model"); assert.equal(options.env.UNSELECTED_CANARY,"must-not-inherit"); assert.equal(options.env.HOME,process.env.HOME); diff --git a/packages/claude-sdk-adapter/tests/startup_budget.test.mjs b/packages/claude-sdk-adapter/tests/startup_budget.test.mjs new file mode 100644 index 000000000..a1a555ce8 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/startup_budget.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import test from "node:test"; + +// Keep the real pinned SDK and bridge; only the native subprocess is controlled. +// A delayed control reply exercises the SDK's deadline without a model request. +const native = ` +import { createInterface } from "node:readline"; +const lines=createInterface({input:process.stdin}); +let timer; +const reply=(id,response)=>process.stdout.write(JSON.stringify({type:"control_response",response:{subtype:"success",request_id:id,response}})+"\\n"); +lines.on("line",line=>{ + const message=JSON.parse(line); + if(message.type==="control_request" && message.request.subtype==="initialize") { + const started=performance.now(); + timer=setTimeout(()=>{ + process.send({kind:"initialized",elapsed:performance.now()-started}); + reply(message.request_id,{commands:[],models:[],account:{},hooks_applied:true}); + },16000); + } else if(message.type==="control_request" && message.request.subtype==="mcp_status") { + process.send({kind:"required_status"}); + reply(message.request_id,{mcpServers:[{name:"fixture",status:"connected",tools:[{name:"echo"}]}]}); + } else if(message.type==="user") process.send({kind:"input"}); + else throw new Error("Unexpected native control"); +}); +lines.on("close",()=>{clearTimeout(timer);process.disconnect();}); +`; + +const childProcess = ` +import { spawn as nativeSpawn } from "node:child_process"; +export function spawn(command,args,options) { + const child=nativeSpawn(process.execPath,["--input-type=module","-e",${JSON.stringify(native)}],{...options,stdio:[...options.stdio,"ipc"]}); + process.send({kind:"native_spawned",pid:child.pid}); + child.on("message",message=>process.send(message)); + child.once("close",()=>process.send({kind:"native_closed"})); + return child; +} +`; +const bridge = ` +import { registerHooks } from "node:module"; +registerHooks({resolve(specifier,context,next){ + if(specifier==="node:child_process" && context.parentURL===${JSON.stringify(new URL("../dist/native.js", import.meta.url).href)}) + return {url:"data:text/javascript,"+encodeURIComponent(${JSON.stringify(childProcess)}),shortCircuit:true}; + return next(specifier,context); +}}); +await import(${JSON.stringify(new URL("../dist/main.js", import.meta.url).href)}); +process.disconnect(); +`; + +test("the SDK accepts delayed initialization on both adapter startup paths", {timeout:45000,concurrency:true}, async t => { + await Promise.all(["executor", "required-mcp"].map(mode => t.test(mode, async t => { + const parent=join(homedir(),".oac","tests"); + mkdirSync(parent,{recursive:true}); + const root=mkdtempSync(join(parent,"claude-startup-budget-")); + const child=spawn(process.execPath,["--input-type=module","-e",bridge],{ + cwd:root,env:{PATH:process.env.PATH,HOME:root,CLAUDE_CONFIG_DIR:root},stdio:["pipe","pipe","pipe","ipc"], + }); + const events=[],observations=[]; + let stderr="",nativePID; + child.stderr.on("data",value=>{stderr+=value}); + const closed=new Promise(resolve=>child.once("close",(code,signal)=>resolve({code,signal}))); + t.after(async()=>{ + if(nativePID)try{process.kill(nativePID,"SIGKILL")}catch{} + child.kill("SIGKILL");await closed;rmSync(root,{recursive:true,force:true}); + }); + const ready=new Promise((resolve,reject)=>{ + const notify=()=>{ + if(mode==="executor"?events.some(event=>event.type==="executor_ready") && observations.some(event=>event.kind==="initialized"):observations.some(event=>event.kind==="input"))resolve(); + }; + createInterface({input:child.stdout}).on("line",line=>{ + const event=JSON.parse(line);events.push(event); + if(event.type==="error")reject(new Error("Preparation failed: "+event.code)); + notify(); + }); + child.on("message",message=>{ + observations.push(message); + if(message.kind==="native_spawned")nativePID=message.pid; + if(message.kind==="native_closed")nativePID=undefined; + notify(); + }); + child.once("close",()=>reject(new Error("Bridge closed before readiness: "+stderr))); + }); + child.stdin.write(JSON.stringify({type:mode==="executor"?"executor_prepare":"start",cwd:root,model:"fixture",system_prompt:"", + ...(mode==="required-mcp"?{input:[{content:[{type:"input_text",text:"held until initialization"}]}], + mcp_http_servers:[{server_label:"fixture",server_url:"https://example.invalid/mcp",allowed_tools:["echo"],required:true}]}:{})})+"\n"); + await ready; + assert.ok(observations.find(event=>event.kind==="initialized").elapsed>=15000); + assert.deepEqual(observations.map(event=>event.kind),mode==="executor"?["native_spawned","initialized"]:["native_spawned","initialized","required_status","input"]); + if(mode==="executor")assert.deepEqual(events,[{type:"executor_ready",protocol:3}]); + child.stdin.end(); + assert.deepEqual(await closed,{code:0,signal:null},stderr); + assert.equal(observations.filter(event=>event.kind==="native_closed").length,1); + }))); +});