Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/claude-sdk-adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions packages/claude-sdk-adapter/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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");
Expand Down
3 changes: 1 addition & 2 deletions packages/claude-sdk-adapter/tests/preparation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
98 changes: 98 additions & 0 deletions packages/claude-sdk-adapter/tests/startup_budget.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
})));
});
Loading