diff --git a/apps/daemon/internal/agent/claudesdk/bridge_frames_test.go b/apps/daemon/internal/agent/claudesdk/bridge_frames_test.go new file mode 100644 index 000000000..fa2126d2c --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/bridge_frames_test.go @@ -0,0 +1,106 @@ +//go:build unix + +package claudesdk + +import ( + "bytes" + "encoding/json" + "os" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// The TypeScript bridge reads the same file in packages/claude-sdk-adapter/tests/bridge_frames.test.mjs. +func TestBridgeFramesMatchSharedFixtures(t *testing.T) { + data, err := os.ReadFile("../../../../../packages/claude-sdk-adapter/fixtures/bridge-frames.json") + if err != nil { + t.Fatal(err) + } + var fixtures struct { + ToBridge []json.RawMessage `json:"to_bridge"` + FromBridge []json.RawMessage `json:"from_bridge"` + } + if err := json.Unmarshal(data, &fixtures); err != nil { + t.Fatal(err) + } + strict := func(raw json.RawMessage, into any) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + return decoder.Decode(into) + } + for _, raw := range fixtures.ToBridge { + var kind struct{ Type string } + if err := json.Unmarshal(raw, &kind); err != nil { + t.Fatal(err) + } + var frame any + switch kind.Type { + case "executor_prepare": + frame = &startRequest{} + case "turn_start": + frame = &turnStartFrame{} + case "turn_cancel": + frame = &turnCancelFrame{} + case "steer": + frame = &steerFrame{} + case "function_result": + frame = &functionResultFrame{} + default: + t.Fatalf("fixture frame %q has no Go producer", kind.Type) + } + if err := strict(raw, frame); err != nil { + t.Fatalf("%s: %v", kind.Type, err) + } + // Encoding must reproduce every fixture field, so no field is dropped or renamed. + encoded, err := json.Marshal(frame) + if err != nil { + t.Fatal(err) + } + var want, got any + if json.Unmarshal(raw, &want) != nil || json.Unmarshal(encoded, &got) != nil { + t.Fatal("invalid JSON") + } + if !bytes.Equal(canonical(t, want), canonical(t, got)) { + t.Errorf("%s: Go encodes\n%s\nfixture\n%s", kind.Type, encoded, raw) + } + } + for _, raw := range fixtures.FromBridge { + var event bridgeEvent + if err := strict(raw, &event); err != nil { + t.Errorf("%s: %v", raw, err) + continue + } + if event.Type == "executor_ready" { + continue + } + // Whatever else the Turn makes of the frame, it must know the kind. + config, req := persistentConfig(t, "replay") + compact := new(bytes.Buffer) + if err := json.Compact(compact, raw); err != nil { + t.Fatal(err) + } + config.Env = append(config.Env, "SDK_REPLAY_FRAME="+compact.String()) + owner, err := NewExecutorFactory(config)(t.Context(), req) + if err != nil { + t.Fatal(err) + } + _, out := consumeExecutorTurn(t, owner, "run", "hello") + for envelope := range out { + var failure proto.ErrorPayload + if envelope.Type == proto.TypeError && envelope.DecodePayload(&failure) == nil && failure.Error == "claudesdk: unknown SDK bridge event" { + t.Errorf("Go does not handle bridge frame kind %q", event.Type) + } + } + _ = owner.Close(t.Context()) + } +} + +func canonical(t *testing.T, value any) []byte { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return data +} diff --git a/apps/daemon/internal/agent/claudesdk/cancellation.go b/apps/daemon/internal/agent/claudesdk/cancellation.go index 01d18b381..e3070a246 100644 --- a/apps/daemon/internal/agent/claudesdk/cancellation.go +++ b/apps/daemon/internal/agent/claudesdk/cancellation.go @@ -32,10 +32,7 @@ func (s *session) Cancel(ctx context.Context) error { if current { stopWrite := context.AfterFunc(ctx, s.invalidate) defer stopWrite() - if err := s.owner.write(struct { - Type string `json:"type"` - TurnID string `json:"turn_id"` - }{"turn_cancel", s.runID}); err != nil { + if err := s.owner.write(turnCancelFrame{"turn_cancel", s.runID}); err != nil { s.invalidate() } } diff --git a/apps/daemon/internal/agent/claudesdk/executor.go b/apps/daemon/internal/agent/claudesdk/executor.go index 6d3826895..4fc143057 100644 --- a/apps/daemon/internal/agent/claudesdk/executor.go +++ b/apps/daemon/internal/agent/claudesdk/executor.go @@ -199,11 +199,7 @@ func (e *executor) StartTurn(ctx context.Context, run string, input proto.Messag go s.runTurn(start, out) // Once the write is attempted, a lost receipt has an unknown input outcome. stopWrite := context.AfterFunc(ctx, s.invalidate) - err := e.write(struct { - Type string `json:"type"` - TurnID string `json:"turn_id"` - Input proto.MessageInput `json:"input"` - }{"turn_start", run, input}) + err := e.write(turnStartFrame{"turn_start", run, input}) stopWrite() if err != nil { s.invalidate() diff --git a/apps/daemon/internal/agent/claudesdk/executor_test.go b/apps/daemon/internal/agent/claudesdk/executor_test.go index 05fdb543e..964f2be19 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_test.go @@ -98,6 +98,15 @@ func runPersistentExecutorHelper() { encode(bridgeEvent{Type: "delta", TurnID: old, ItemID: "item", Delta: "late"}) continue } + if frame := os.Getenv("SDK_REPLAY_FRAME"); frame != "" { + // Replay one shared-fixture frame as this Turn's only bridge output. + var fields map[string]any + _ = json.Unmarshal([]byte(frame), &fields) + fields["turn_id"] = active + _ = json.NewEncoder(os.Stdout).Encode(fields) + settle(true) + continue + } encode(bridgeEvent{Type: "turn_started", TurnID: active}) encode(bridgeEvent{Type: "input_ready", TurnID: active, SessionID: "native-persistent"}) encode(bridgeEvent{Type: "delta", TurnID: active, ItemID: "item", Delta: "partial"}) diff --git a/apps/daemon/internal/agent/claudesdk/functions.go b/apps/daemon/internal/agent/claudesdk/functions.go index 6aef261f1..972c6b39b 100644 --- a/apps/daemon/internal/agent/claudesdk/functions.go +++ b/apps/daemon/internal/agent/claudesdk/functions.go @@ -128,11 +128,7 @@ func (s *session) SubmitFunctionResult(ctx context.Context, result proto.Functio if err := result.ValidateContent(); err != nil { return err } - data, err := json.Marshal(struct { - Type string `json:"type"` - TurnID string `json:"turn_id"` - proto.FunctionResultPayload - }{Type: "function_result", TurnID: s.runID, FunctionResultPayload: result}) + data, err := json.Marshal(functionResultFrame{Type: "function_result", TurnID: s.runID, FunctionResultPayload: result}) if err != nil { return err } diff --git a/apps/daemon/internal/agent/claudesdk/session.go b/apps/daemon/internal/agent/claudesdk/session.go index 216cbd522..d0bea805c 100644 --- a/apps/daemon/internal/agent/claudesdk/session.go +++ b/apps/daemon/internal/agent/claudesdk/session.go @@ -31,6 +31,32 @@ type session struct { outcome proto.DonePayload } +// Frames the Executor writes to the bridge; bridgeEvent is what the bridge sends back. +// packages/claude-sdk-adapter/fixtures/bridge-frames.json is checked against both. +type turnStartFrame struct { + Type string `json:"type"` + TurnID string `json:"turn_id"` + Input proto.MessageInput `json:"input"` +} + +type turnCancelFrame struct { + Type string `json:"type"` + TurnID string `json:"turn_id"` +} + +type steerFrame struct { + Type string `json:"type"` + TurnID string `json:"turn_id"` + InputID string `json:"input_id"` + Input proto.MessageInput `json:"input"` +} + +type functionResultFrame struct { + Type string `json:"type"` + TurnID string `json:"turn_id"` + proto.FunctionResultPayload +} + type bridgeEvent struct { EngineErrorCode json.RawMessage `json:"engine_error_code"` TurnID string `json:"turn_id"` diff --git a/apps/daemon/internal/agent/claudesdk/steering.go b/apps/daemon/internal/agent/claudesdk/steering.go index 4db08b1b3..dbe660c0e 100644 --- a/apps/daemon/internal/agent/claudesdk/steering.go +++ b/apps/daemon/internal/agent/claudesdk/steering.go @@ -38,12 +38,7 @@ func (s *session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP if strings.TrimSpace(input.InputID) == "" || len(input.InputID) > 256 || input.Input.Validate() != nil { return fmt.Errorf("%w: input identity and text are required", agent.ErrSteeringRejected) } - data, err := json.Marshal(struct { - Type string `json:"type"` - TurnID string `json:"turn_id"` - InputID string `json:"input_id"` - Input proto.MessageInput `json:"input"` - }{Type: "steer", TurnID: s.runID, InputID: input.InputID, Input: input.Input}) + data, err := json.Marshal(steerFrame{Type: "steer", TurnID: s.runID, InputID: input.InputID, Input: input.Input}) if err != nil || len(data) > 1024*1024 { return fmt.Errorf("%w: input exceeds bridge limit", agent.ErrSteeringRejected) } diff --git a/packages/claude-sdk-adapter/README.md b/packages/claude-sdk-adapter/README.md index f4c55e8e8..155221e2f 100644 --- a/packages/claude-sdk-adapter/README.md +++ b/packages/claude-sdk-adapter/README.md @@ -20,7 +20,7 @@ The package test compiles TypeScript before running its tests. The Make target a ### Bridge protocol -`packages/claude-sdk-adapter` privately owns the pinned official TypeScript SDK and native message translation. The Go `claudesdk.NewExecutorFactory` uses the shared owned process runner and emits the daemon's delta, error and Done frames. The SDK owns the model loop. Its narrow stdio protocol carries Executor preparation and identified Turn starts, text deltas, function calls/results/receipts, active input/receipts, usage snapshots and terminal result/error plus settlement; native translation stays inside the adapter. The private `native_model_options` input contains only the native options compiled by Go after shared Harness validation. The bridge checks object/field structure and maps those fields explicitly to SDK options; enum membership, budget ranges and thinking combinations belong solely to the Go adapter declaration. The public `harness_config` object does not cross this private boundary. +`packages/claude-sdk-adapter` privately owns the pinned official TypeScript SDK and native message translation. The Go `claudesdk.NewExecutorFactory` uses the shared owned process runner and emits the daemon's delta, error and Done frames. The SDK owns the model loop. Its narrow stdio protocol carries Executor preparation and identified Turn starts, text deltas, function calls/results/receipts, active input/receipts, usage snapshots and terminal result/error plus settlement; native translation stays inside the adapter. `fixtures/bridge-frames.json` holds one example of every frame kind in each direction. The Go package test decodes it strictly into the Go frame types and replays each bridge frame through a Turn to confirm Go handles its kind. The TypeScript tests feed it to the bridge parsers and check that every frame the bridge emits in them matches a fixture kind and its fields; a frame or field added on one side fails the other side's test until the fixture changes. The private `native_model_options` input contains only the native options compiled by Go after shared Harness validation. The bridge checks object/field structure and maps those fields explicitly to SDK options; enum membership, budget ranges and thinking combinations belong solely to the Go adapter declaration. The public `harness_config` object does not cross this private boundary. It also emits the neutral `output_message` start/completion snapshots and tags deltas with the native Messages API message ID, not the SDK event UUID. Text blocks in one native message share that identity. The SDK's per-block assistant snapshots replace draft block text; only native `message_stop` completes the message, without replaying its text as another delta. Thinking/tool-only messages produce no text Items; interrupted messages retain their streamed partial text. No phase is inferred from the final result. Turn-owned native work and output draining precede reuse. Executor close releases the SDK Query and native process. The private `turn_settled` frame requires `confirmed` independently of `reusable`: confirmed native Turn/cancellation settlement, confirmed resource cleanup, and reuse eligibility are separate facts. Unknown or nonempty interrupt receipts and unsettled input/function/tool work remain unconfirmed even after successful teardown. Go rejects cancellation and AwaitSettlement when native confirmation is missing or false, or its own receipt ledger remains unsettled. A confirmed Turn may be non-reusable after cleanup; that state alone does not turn a verified cancellation into an error. Confirmed native cancellation may settle unanswered function calls after result admission closes and callbacks drain. A submitted function result still requires its native application receipt, including when the MCP request aborts. diff --git a/packages/claude-sdk-adapter/fixtures/bridge-frames.json b/packages/claude-sdk-adapter/fixtures/bridge-frames.json new file mode 100644 index 000000000..c4b645c96 --- /dev/null +++ b/packages/claude-sdk-adapter/fixtures/bridge-frames.json @@ -0,0 +1,87 @@ +{ + "to_bridge": [ + { + "type": "executor_prepare", + "model": "claude-fixture", + "system_prompt": "Answer briefly.", + "cwd": "/tmp", + "resume": "native-session", + "native_model_options": { "effort": "high", "thinking": { "type": "enabled", "budgetTokens": 2048, "display": "summarized" } }, + "output_format": { "type": "json_schema", "schema": { "type": "object" } }, + "subagents": { "max_concurrent": 2 }, + "tool_search": true, + "reasoning_summary": true, + "web_search": "live", + "functions": [{ "name": "lookup", "description": "Look a value up.", "parameters": { "type": "object" }, "defer_loading": true }], + "mcp_http_servers": [{ + "server_label": "docs", + "server_url": "https://example.com/mcp", + "allowed_tools": ["search"], + "required": true, + "bearer_token_env_var": "OAC_RUNTIME_MCP_BEARER_AAAAAAAAAAAAAAAAAAAAAAAAAA", + "headers": { "x-fixture": "1" } + }] + }, + { + "type": "executor_prepare", + "model": "claude-fixture", + "system_prompt": "", + "cwd": "/tmp", + "require_history": true, + "workspace": { + "tool_env": { "FIXTURE": "1" }, + "capability_root": "/tmp", + "mcp": [{ + "server_label": "local", + "command": "/usr/bin/runtime", + "args": ["runtime-mcp-exec", "/tmp", "mcp/local", "local"], + "allowed_tools": null + }, { + "server_label": "declared", + "command": "server", + "args": [], + "allowed_tools": null, + "declared": true + }], + "skills": [{ "metadata": { "type": "inline", "name": "fixture", "description": "A fixture skill." }, "relative_root": "skills/fixture", "package_root": "skills" }], + "home": "/tmp", + "state": "/tmp", + "scratch": "/tmp", + "env_names": ["ANTHROPIC_API_KEY"] + } + }, + { "type": "turn_start", "turn_id": "run-1", "input": [{ "content": [{ "type": "input_text", "text": "Hello" }] }] }, + { "type": "turn_cancel", "turn_id": "run-1" }, + { "type": "steer", "turn_id": "run-1", "input_id": "input-1", "input": [{ "content": [{ "type": "input_text", "text": "Also this" }] }] }, + { "type": "function_result", "turn_id": "run-1", "call_id": "call-1", "delivery_id": "delivery-1", "success": true, "content": [{ "type": "input_text", "text": "42" }] } + ], + "from_bridge": [ + { "type": "executor_ready", "protocol": 3 }, + { "type": "turn_started", "turn_id": "run-1" }, + { "type": "turn_settled", "turn_id": "run-1", "confirmed": true, "reusable": false, "reason": "native_error" }, + { "type": "input_ready", "turn_id": "run-1", "session_id": "native-session" }, + { "type": "input_closed", "turn_id": "run-1", "session_id": "native-session" }, + { "type": "input_applied", "turn_id": "run-1", "input_id": "input-1" }, + { "type": "input_rejected", "turn_id": "run-1", "input_id": "input-1" }, + { "type": "delta", "turn_id": "run-1", "item_id": "message-1", "delta": "Hi" }, + { "type": "output_message", "turn_id": "run-1", "message": { "id": "message-1", "status": "completed", "text": "Hi" } }, + { "type": "reasoning", "turn_id": "run-1", "reasoning": { "id": "message-1:0", "status": "in_progress", "delta": "Thinking" } }, + { "type": "function_call", "turn_id": "run-1", "call": { "call_id": "call-1", "name": "lookup", "arguments": { "text": "x" }, "native_id": "child-1", "turn_id": "child-turn-1" } }, + { "type": "function_withdrawn", "turn_id": "run-1", "withdrawn": { "call_id": "call-1", "native_id": "child-1", "turn_id": "child-turn-1" } }, + { "type": "function_applied", "turn_id": "run-1", "call_id": "call-1", "delivery_id": "delivery-1" }, + { "type": "command_observation", "turn_id": "run-1", "session_id": "native-session", "id": "tool-1", "stage": "after", "observation": { "kind": "command", "status": "completed", "command": "ls", "output": "a" } }, + { "type": "search_observation", "turn_id": "run-1", "session_id": "native-session", "id": "tool-2", "stage": "before", "observation": { "kind": "web_search", "status": "in_progress", "action": { "type": "search", "query": "oac" } } }, + { "type": "mcp_observation", "turn_id": "run-1", "id": "tool-3", "stage": "after", "observation": { "kind": "mcp", "status": "completed", "server": "docs", "name": "search", "arguments": {}, "output": "ok", "error": null } }, + { "type": "subagent_identity", "turn_id": "run-1", "fact": { "native_id": "child-1" } }, + { "type": "subagent_turn", "turn_id": "run-1", "fact": { "native_id": "child-1" } }, + { "type": "subagent_item", "turn_id": "run-1", "fact": { "native_id": "child-1" } }, + { "type": "subagent_coordination", "turn_id": "run-1", "fact": { "native_id": "child-1" } }, + { + "type": "usage", "turn_id": "run-1", "session_id": "native-session", "result_id": "result-1", + "usage": { "usage": { "input_tokens": 1 }, "modelUsage": {}, "total_cost_usd": 0.01, "subtype": "success", "is_error": false, + "scopes": { "usage": "native_turn_main_loop", "modelUsage": "query_cumulative", "total_cost_usd": "query_cumulative_estimate" } } + }, + { "type": "result", "turn_id": "run-1", "session_id": "native-session", "text": "Hi" }, + { "type": "error", "turn_id": "run-1", "code": "execution_failed", "engine_error_code": "rate_limit", "session_id": "native-session", "result_id": "result-1" } + ] +} diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts index c11468734..142c41c51 100644 --- a/packages/claude-sdk-adapter/src/adapter.ts +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -5,7 +5,7 @@ import { Subagents } from "./subagents.js"; import type { Fact } from "./subagent_history.js"; import { getSessionInfo, query, startup, type HookCallback, type McpServerConfig, type Options, type WarmQuery } from "@anthropic-ai/claude-agent-sdk"; import { Inputs, type InputEvent } from "./inputs.js"; -import { executorResultUsage, resultUsage, type NativeUsage } from "./usage.js"; +import { executorResultUsage, type NativeUsage } from "./usage.js"; import { spawnNative } from "./native.js"; import { MessageObserver, type MessageEvent } from "./messages.js"; import { createFunctionServer, type FunctionCall } from "./functions.js"; @@ -15,9 +15,8 @@ import { MCPObserver, type MCPEvent } from "./mcp_observer.js"; import { CommandObserver, type CommandEvent } from "./command_observer.js"; import { SearchObserver, type SearchEvent } from "./search_observer.js"; import { WorkspaceProfile } from "./workspace.js"; -import { immediateInput, type Prepare, type Start, type ExecutorPrepare } from "./request.js"; +import type { ExecutorPrepare } from "./request.js"; import { recoverSession } from "./recovery.js"; -export { parseStart, type Start } from "./request.js"; export type Event = | ExecutorEvent @@ -28,15 +27,14 @@ export type Event = | MCPEvent | CommandEvent | SearchEvent - | { type: "prepared" } | { type: "usage"; session_id: string; result_id: string; usage: NativeUsage } - | { type: "delta"; delta: string } | { type: "result"; session_id: string; text: string } | { type: "error"; code: "invalid_request" | "history_unavailable" | "execution_failed" | "cancelled"; engine_error_code?: string; session_id?: string; result_id?: string }; -export async function execute(request: Start | Prepare | ExecutorPrepare, emit: (event: Event) => Promise, abort: AbortController, functions = new FunctionBridge(emit), inputs = new Inputs(immediateInput(request)), turns?: ExecutorTurns): Promise { - const ownerEmit=emit; - if(turns) emit=event=>turns.emit(event); +export async function execute(request: ExecutorPrepare, ownerEmit: (event: Event) => Promise, abort: AbortController, turns: ExecutorTurns): Promise { + const emit = (event: Event) => turns.emit(event); + let functions = new FunctionBridge(emit); + let inputs = new Inputs(); const nativeFailure = new NativeFailure(); const nativeResultFailure = new Error("unsuccessful native result"); let classifiedFailure: { engine_error_code: string; session_id: string; result_id: string } | undefined; @@ -51,7 +49,6 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: let commands = workspace ? new CommandObserver() : undefined; const observeSearch = workspace !== undefined || request.web_search === "live"; let searches = observeSearch ? new SearchObserver() : undefined; - if (request.type === "prepare" && !workspace) throw new Error("invalid_request"); if (workspace && "mcp_http_servers" in request) throw new Error("invalid_request"); if (request.require_history && !request.resume) { const recovered = await recoverSession(request.cwd); @@ -76,7 +73,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: for (const fact of child?.facts ?? []) await emit(fact); return target.invoke(child ? { ...call, child: { native_id: child.native_id, turn_id: child.turn_id } } : call, signal); }; - if (definitions.length) mcpServers.functions = createFunctionServer(definitions, (call,signal) => { const target=functions; return turns ? turns.track(()=>invoke(target,call,signal)) : invoke(target,call,signal); }); + if (definitions.length) mcpServers.functions = createFunctionServer(definitions, (call,signal) => { const target=functions; return turns.track(()=>invoke(target,call,signal)); }); let mcp = profile ? new MCPObserver(profile.identities) : undefined; if (profile) Object.assign(mcpServers, profile.servers); const children: Promise[] = []; @@ -90,7 +87,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: let stream: ReturnType | undefined; let warm: WarmQuery | undefined; let nativeAlive = false; - const closeInputs = () => { inputs.close(); turns?.close(); }; + const closeInputs = () => { inputs.close(); turns.close(); }; abort.signal.addEventListener("abort", closeInputs, { once: true }); try { if (abort.signal.aborted) throw new Error("cancelled"); @@ -142,58 +139,44 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: }; // ENABLE_TOOL_SEARCH=true is the official mapping of tool_search; native owns discovery and provider policy. if (request.tool_search) options.env = { ...(options.env ?? process.env), ENABLE_TOOL_SEARCH: "true" }; - if(turns) { - for(const matchers of Object.values(options.hooks ?? {})) for(const matcher of matchers ?? []) { - matcher.hooks=matcher.hooks.map(hook=>(...args)=>turns.track(()=>hook(...args))); - } - const canUseTool=options.canUseTool; - if(canUseTool) options.canUseTool=(...args)=>turns.track(()=>canUseTool(...args)); + for(const matchers of Object.values(options.hooks ?? {})) for(const matcher of matchers ?? []) { + matcher.hooks=matcher.hooks.map(hook=>(...args)=>turns.track(()=>hook(...args))); } - if (request.type === "prepare" || request.type === "executor_prepare") { - warm = await startup({ options, initializeTimeoutMs: 15000 }); - 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)); - if ((hooksRequested && initialized.hooks_applied !== true) || children.length !== 1 || !nativeAlive || abort.signal.aborted) { - throw new Error("preparation unavailable"); - } - if (declarations?.some(server => "required" in server && server.required)) profile?.verifyRequired(await stream.mcpServerStatus()); - if(turns) { - turns.configure(stream,(input,output)=>{ - inputs=new Inputs(input); - functions=new FunctionBridge(output); - messages=new MessageObserver(request.reasoning_summary); - structured=request.output_format ? new StructuredOutput() : undefined; - commands=workspace ? new CommandObserver() : undefined; - searches=observeSearch ? new SearchObserver() : undefined; - mcp=profile ? new MCPObserver(profile.identities) : undefined; - subagents?.beginTurn(); - return inputs; - },async value=>{ - if(value.type==="steer") for(const event of inputs.submit(value)) await emit(event); - else functions.submit(JSON.stringify(value)); - }); - await ownerEmit({type:"executor_ready",protocol:3}); - } 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 }); - stream = warm.query(turns ?? inputs); - const initialized = await stream.initializationResult(); - if (initialized.hooks_applied !== true || children.length !== 1) throw new Error("MCP initialization unavailable"); - if (declarations?.some(server => "required" in server && server.required)) profile.verifyRequired(await stream.mcpServerStatus()); - if (abort.signal.aborted || !nativeAlive) throw new Error("MCP initialization interrupted"); - inputs.release(request.input); - } else stream = query({ prompt: inputs, options }); + const canUseTool=options.canUseTool; + if(canUseTool) options.canUseTool=(...args)=>turns.track(()=>canUseTool(...args)); + warm = await startup({ options, initializeTimeoutMs: 15000 }); + stream = warm.query(turns); + const initialized = await stream.initializationResult(); + const hooksRequested = Object.values(options.hooks ?? {}).some(matchers => matchers?.some(matcher => matcher.hooks.length > 0)); + if ((hooksRequested && initialized.hooks_applied !== true) || children.length !== 1 || !nativeAlive || abort.signal.aborted) { + throw new Error("preparation unavailable"); + } + if (declarations?.some(server => "required" in server && server.required)) profile?.verifyRequired(await stream.mcpServerStatus()); + if (abort.signal.aborted) throw new Error("preparation unavailable"); + turns.configure(stream,(input,output)=>{ + inputs=new Inputs(input); + functions=new FunctionBridge(output); + messages=new MessageObserver(request.reasoning_summary); + structured=request.output_format ? new StructuredOutput() : undefined; + commands=workspace ? new CommandObserver() : undefined; + searches=observeSearch ? new SearchObserver() : undefined; + mcp=profile ? new MCPObserver(profile.identities) : undefined; + subagents?.beginTurn(); + return inputs; + },async value=>{ + if(value.type==="steer") for(const event of inputs.submit(value)) await emit(event); + else functions.submit(JSON.stringify(value)); + }); + await ownerEmit({type:"executor_ready",protocol:3}); for await (const message of stream) { // Native command queue telemetry can follow a result. It is not model // output or a consumed-input receipt, and has no authority over a Turn. if (isCommandLifecycle(message)) continue; - if(turns && !turns.id && !(message.type==="system" && message.subtype==="init")) throw new Error("unbound native event"); + if(!turns.id && !(message.type==="system" && message.subtype==="init")) throw new Error("unbound native event"); const errorCode = nativeFailure.observe(message, nativeID, inputs.pendingInputIDs(message)); subagents?.consume(message); structured?.consume(message, nativeID); - await functions.consume(message, nativeID, turns?.cancelled); + await functions.consume(message, nativeID, turns.cancelled); if (mcp) for (const event of mcp.consume(message, nativeID)) await emit(event); if (commands) for (const event of commands.consume(message, nativeID, inputs.hasInput)) await emit(event); if (searches) for (const event of searches.consume(message, nativeID)) await emit(event); @@ -214,23 +197,23 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: message.mcp_servers.some(server => server.name !== "functions" || server.status !== "connected"))) { throw new Error("unexpected native configuration"); } - turns?.identify(nativeID); - if(!turns || turns.id) for (const event of inputs.start(nativeID)) await emit(event); + turns.identify(nativeID); + if(turns.id) for (const event of inputs.start(nativeID)) await emit(event); } else if (message.type === "result") { if (!message.uuid || resultIDs.has(message.uuid) || !nativeID || message.session_id !== nativeID) throw new Error("invalid native result identity"); resultIDs.add(message.uuid); - await emit({ type: "usage", session_id: nativeID, result_id: message.uuid, usage: turns ? executorResultUsage(message) : resultUsage(message) }); + await emit({ type: "usage", session_id: nativeID, result_id: message.uuid, usage: executorResultUsage(message) }); for (const event of inputs.consume(message)) await emit(event); - if ((message.subtype !== "success" || message.is_error) && !turns?.cancelled) { + if ((message.subtype !== "success" || message.is_error) && !turns.cancelled) { if (errorCode) classifiedFailure = { engine_error_code: errorCode, session_id: nativeID, result_id: message.uuid }; throw nativeResultFailure; } - if (structured && !turns?.cancelled) await emit(structured.complete(message)); + if (structured && !turns.cancelled) await emit(structured.complete(message)); result = { type: "result", session_id: nativeID, text: message.subtype === "success" ? message.result : "" }; - if(turns?.cancelled && !inputs.complete) throw new Error("unconfirmed cancelled inputs"); + if(turns.cancelled && !inputs.complete) throw new Error("unconfirmed cancelled inputs"); } - if (message.type !== "result" && (!turns || turns.id)) for (const event of inputs.consume(message)) await emit(event); - if(turns && result && inputs.complete && (!subagents || subagents.complete)) { + if (message.type !== "result" && turns.id) for (const event of inputs.consume(message)) await emit(event); + if(result && inputs.complete && (!subagents || subagents.complete)) { if (turns.cancelled) functions.cancelUnanswered(); if(!await turns.quiescent() || abort.signal.aborted) throw new Error("unconfirmed interrupt"); functions.assertComplete(); mcp?.assertComplete(); commands?.assertComplete(); searches?.assertComplete(); @@ -241,7 +224,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: result=undefined; } } - if (subagents && (!turns || turns.id)) for (const event of await subagents.facts()) await emit(event); + if (subagents && turns.id) for (const event of await subagents.facts()) await emit(event); functions.assertComplete(); mcp?.assertComplete(); commands?.assertComplete(); @@ -252,16 +235,16 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: } finally { profile?.close(); inputs.close(); - turns?.close(); + turns.close(); functions.close(); stream?.close(); warm?.close(); abort.signal.removeEventListener("abort", closeInputs); const exits = await Promise.all(children); if (!exits.length) classifiedFailure = undefined; - await turns?.drain(); + await turns.drain(); if (!exits.length || exits.some(code => code !== 0)) failed = true; - if (subagents && (!turns || turns.id) && abort.signal.aborted && exits.length === 1) { + if (subagents && turns.id && abort.signal.aborted && exits.length === 1) { try { for (const event of await subagents.facts(Date.now())) await emit(event); } catch { cancellationFactsFailed = true; } } @@ -269,17 +252,10 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: if (commands) for (const event of commands.close()) await emit(event); if (searches) for (const event of searches.close(nativeID)) await emit(event); } - if(turns) { - if(turns.id) { - await emit({type:"error",code: turns.cancelled && !cancellationFactsFailed ? "cancelled" : "execution_failed", ...(!turns.cancelled && !cancellationFactsFailed ? classifiedFailure : undefined)}); - await turns.settled(false, false, turns.cancelled ? "cancellation_unconfirmed" : "native_execution_unavailable"); - } else if(failed && !abort.signal.aborted) await ownerEmit({type:"error",code:"execution_failed"}); - return; - } - if (cancellationFactsFailed) await emit({ type: "error", code: "execution_failed" }); - else if (abort.signal.aborted) await emit({ type: "error", code: "cancelled" }); - else if (failed || !result || !inputs.complete) await emit({ type: "error", code: "execution_failed", ...classifiedFailure }); - else await emit(result); + if(turns.id) { + await emit({type:"error",code: turns.cancelled && !cancellationFactsFailed ? "cancelled" : "execution_failed", ...(!turns.cancelled && !cancellationFactsFailed ? classifiedFailure : undefined)}); + await turns.settled(false, false, turns.cancelled ? "cancellation_unconfirmed" : "native_execution_unavailable"); + } else if(failed && !abort.signal.aborted) await ownerEmit({type:"error",code:"execution_failed"}); } function isCommandLifecycle(message: unknown): boolean { diff --git a/packages/claude-sdk-adapter/src/inputs.ts b/packages/claude-sdk-adapter/src/inputs.ts index fa5957fbf..2f3de7631 100644 --- a/packages/claude-sdk-adapter/src/inputs.ts +++ b/packages/claude-sdk-adapter/src/inputs.ts @@ -19,7 +19,7 @@ export class Inputs implements AsyncIterable { constructor(prompt?: MessageInput) { if (prompt !== undefined) this.release(prompt); } - release(prompt: MessageInput): void { + private release(prompt: MessageInput): void { if (this.ended || this.submitted.size || prompt.length > 64) throw new Error("Invalid initial input."); this.enqueue(parseMessageInput(prompt)); } diff --git a/packages/claude-sdk-adapter/src/main.ts b/packages/claude-sdk-adapter/src/main.ts index 891e77080..25f5672e0 100644 --- a/packages/claude-sdk-adapter/src/main.ts +++ b/packages/claude-sdk-adapter/src/main.ts @@ -1,9 +1,7 @@ import { ExecutorTurns } from "./executor_protocol.js"; -import { Inputs } from "./inputs.js"; -import { FunctionBridge } from "./function_bridge.js"; import { createInterface } from "node:readline"; -import { execute, type Event } from "./adapter.js"; -import { immediateInput, parseRequest, preparedInput } from "./request.js"; +import { execute } from "./adapter.js"; +import { parseRequest } from "./request.js"; const abort = new AbortController(); const lines = createInterface({ input: process.stdin, crlfDelay: Infinity }); @@ -11,7 +9,7 @@ const stop = () => abort.abort(); process.once("SIGTERM", stop); process.once("SIGINT", stop); lines.once("close", stop); -const emit = (event: Event | {type:string;[key:string]:unknown}): Promise => new Promise((resolve, reject) => { +const emit = (event: {type:string;[key:string]:unknown}): Promise => new Promise((resolve, reject) => { process.stdout.write(JSON.stringify(event) + "\n", error => error ? reject(error) : resolve()); }); try { @@ -19,42 +17,24 @@ try { const first = await input.next(); if (first.done || Buffer.byteLength(first.value) > 1024 * 1024) throw new Error("invalid_request"); const request = parseRequest(first.value); - let phase = request.type === "prepare" ? "preparing" : "running"; - let invalid = false; - const output = async (event: Event) => { - if (event.type === "prepared") phase = "prepared"; - await emit(invalid && (event.type === "error" || event.type === "result") ? { type: "error", code: "invalid_request" } : event); - }; - const functions = new FunctionBridge(output); - const prompts = new Inputs(immediateInput(request)); - const turns=request.type==="executor_prepare" ? new ExecutorTurns(emit,abort) : undefined; + const turns = new ExecutorTurns(emit, abort); const incoming = (async () => { try { for await (const line of { [Symbol.asyncIterator]: () => input }) { if (Buffer.byteLength(line) > 1024 * 1024) throw new Error("Invalid input."); - const value: unknown = JSON.parse(line); - if(turns) { - if(!value || typeof value!=="object" || Array.isArray(value)) throw new Error("invalid_request"); - const control=value as Record; - if(control.type==="turn_start") await turns.start(control); - else if(control.type==="turn_cancel") await turns.cancel(control); - else await turns.submit(control); - } else if (request.type === "prepare" && phase !== "running") { - if (phase !== "prepared" || abort.signal.aborted) throw new Error("invalid_request"); - prompts.release(preparedInput(value)); - phase = "running"; - } else if (value && typeof value === "object" && "type" in value && value.type === "steer") { - for (const event of prompts.submit(value)) await output(event); - } else functions.submit(line); + const control: unknown = JSON.parse(line); + if (!control || typeof control !== "object" || Array.isArray(control)) throw new Error("invalid_request"); + const value = control as Record; + if (value.type === "turn_start") await turns.start(value); + else if (value.type === "turn_cancel") await turns.cancel(value); + else await turns.submit(value); } } - catch { invalid = request.type === "prepare"; abort.abort(); } + catch { abort.abort(); } })(); - try { await execute(request, output, abort, functions, prompts, turns); } - catch { await output({ type: "error", code: "execution_failed" }); } + try { await execute(request, emit, abort, turns); } + catch { await emit({ type: "error", code: "execution_failed" }); } finally { - prompts.close(); - functions.close(); lines.removeListener("close", stop); lines.close(); process.stdin.destroy(); diff --git a/packages/claude-sdk-adapter/src/request.ts b/packages/claude-sdk-adapter/src/request.ts index 1b00a08e0..fb91622f8 100644 --- a/packages/claude-sdk-adapter/src/request.ts +++ b/packages/claude-sdk-adapter/src/request.ts @@ -1,4 +1,3 @@ -import { parseMessageInput, type MessageInput } from "./message_input.js"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { isAbsolute } from "node:path"; import { parseHTTPServers, type HTTPServer } from "./mcp.js"; @@ -6,11 +5,10 @@ import { parseWorkspace, type Workspace } from "./workspace.js"; import { parseNativeModelOptions, type NativeModelOptions } from "./native_model_options.js"; -export type Start = { +export type ExecutorPrepare = { native_model_options?: NativeModelOptions; - type: "start"; + type: "executor_prepare"; output_format?: { type: "json_schema"; schema: Record }; - input: MessageInput; model: string; system_prompt: string; cwd: string; @@ -24,22 +22,14 @@ export type Start = { mcp_http_servers?: HTTPServer[]; workspace?: Workspace; }; -export type ExecutorPrepare = Omit & { type: "executor_prepare" }; -export type Prepare = Omit & { type: "prepare"; workspace: Workspace }; -// MCP startup confirms its hooks before the native input iterator yields. -export function immediateInput(request: Start | Prepare | ExecutorPrepare): MessageInput | undefined { - return request.type === "start" && request.mcp_http_servers === undefined && !request.workspace?.mcp?.length ? request.input : undefined; -} - -export function parseRequest(line: string): Start | Prepare | ExecutorPrepare { +export function parseRequest(line: string): ExecutorPrepare { const value: unknown = JSON.parse(line); if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); const request = value as Record; - const allowed = new Set(["native_model_options", "type", "input", "model", "system_prompt", "cwd", "resume", "require_history", "output_format", "subagents", "functions", "tool_search", "reasoning_summary", "web_search", "mcp_http_servers", "workspace"]); + const allowed = new Set(["native_model_options", "type", "model", "system_prompt", "cwd", "resume", "require_history", "output_format", "subagents", "functions", "tool_search", "reasoning_summary", "web_search", "mcp_http_servers", "workspace"]); if (Object.keys(request).some(key => !allowed.has(key)) || - (request.type !== "start" && request.type !== "prepare" && request.type !== "executor_prepare") || - (request.type === "start" ? !Array.isArray(request.input) : "input" in request) || + request.type !== "executor_prepare" || typeof request.model !== "string" || !request.model.trim() || typeof request.system_prompt !== "string" || typeof request.cwd !== "string" || !isAbsolute(request.cwd) || @@ -51,7 +41,7 @@ export function parseRequest(line: string): Start | Prepare | ExecutorPrepare { !tool || typeof tool.name !== "string" || !tool.name || typeof tool.description !== "string" || (tool.defer_loading !== undefined && typeof tool.defer_loading !== "boolean") || !tool.parameters || tool.parameters.type !== "object"))) throw new Error("invalid_request"); - const deferred = (request.functions as Start["functions"])?.some(tool => tool.defer_loading) ?? false; + const deferred = (request.functions as ExecutorPrepare["functions"])?.some(tool => tool.defer_loading) ?? false; if ((request.tool_search !== undefined && typeof request.tool_search !== "boolean") || (!!request.tool_search !== deferred)) throw new Error("invalid_request"); if (request.subagents !== undefined) { @@ -59,34 +49,14 @@ export function parseRequest(line: string): Start | Prepare | ExecutorPrepare { if (!value || typeof value !== "object" || Object.keys(value).length !== 1 || !Number.isSafeInteger(value.max_concurrent) || (value.max_concurrent as number) < 1) throw new Error("invalid_request"); } if (request.output_format !== undefined) { - const format = request.output_format as Start["output_format"]; + const format = request.output_format as ExecutorPrepare["output_format"]; if (!format || format.type !== "json_schema" || Object.keys(format).some(key => !["type", "schema"].includes(key)) || !format.schema || format.schema.type !== "object") throw new Error("invalid_request"); } - if (request.type === "start") requestInput(request.input); parseNativeModelOptions(request.native_model_options); parseHTTPServers(request.mcp_http_servers); const workspace = parseWorkspace(request.workspace, request.cwd); if (request.require_history && !workspace) throw new Error("invalid_request"); - if ((workspace && "mcp_http_servers" in request) || - (request.type === "prepare" && !workspace)) throw new Error("invalid_request"); - return request as Start | Prepare | ExecutorPrepare; -} - -export function parseStart(line: string): Start { - const request = parseRequest(line); - if (request.type !== "start") throw new Error("invalid_request"); - return request; -} - -export function preparedInput(value: unknown): MessageInput { - if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); - const request = value as Record; - if (request.type !== "start" || Object.keys(request).some(key => key !== "type" && key !== "input") || - !Array.isArray(request.input)) throw new Error("invalid_request"); - return requestInput(request.input); -} - -function requestInput(value: unknown): MessageInput { - try { return parseMessageInput(value); } catch { throw new Error("invalid_request"); } + if (workspace && "mcp_http_servers" in request) throw new Error("invalid_request"); + return request as ExecutorPrepare; } diff --git a/packages/claude-sdk-adapter/tests/bridge_frames.test.mjs b/packages/claude-sdk-adapter/tests/bridge_frames.test.mjs new file mode 100644 index 000000000..259de9e67 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/bridge_frames.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ExecutorTurns } from "../dist/executor_protocol.js"; +import { FunctionBridge } from "../dist/function_bridge.js"; +import { Inputs } from "../dist/inputs.js"; +import { parseRequest } from "../dist/request.js"; +import { assertFrame, frames } from "./frames.mjs"; + +test("the bridge accepts every frame Go sends", async () => { + for (const frame of frames.to_bridge.filter(frame => frame.type === "executor_prepare")) parseRequest(JSON.stringify(frame)); + + const events = []; + const turns = new ExecutorTurns(async event => { events.push(event); }, new AbortController()); + const functions = new FunctionBridge(async event => { events.push(event); }); + let inputs; + turns.configure({ interrupt: async () => ({ still_queued: [] }) }, input => (inputs = new Inputs(input)), async payload => { + if (payload.type === "steer") events.push(...inputs.submit(payload)); + else functions.submit(JSON.stringify(payload)); + }); + const [start, cancel, steer, result] = ["turn_start", "turn_cancel", "steer", "function_result"].map(type => frames.to_bridge.find(frame => frame.type === type)); + await turns.start(start); + events.push(...inputs.start("native-session")); + const call = functions.invoke({ id: result.call_id, name: "lookup", arguments: {} }, new AbortController().signal); + await turns.submit(steer); + await turns.submit(result); + await call; + await turns.cancel(cancel); + await turns.settled(true, false, "native_error"); + assert.deepEqual(events.map(event => event.type), ["turn_started", "input_ready", "function_call", "turn_settled"]); + for (const event of events) assertFrame(event); +}); diff --git a/packages/claude-sdk-adapter/tests/classified_execution.test.mjs b/packages/claude-sdk-adapter/tests/classified_execution.test.mjs index 9a40bcf2f..fac75dedb 100644 --- a/packages/claude-sdk-adapter/tests/classified_execution.test.mjs +++ b/packages/claude-sdk-adapter/tests/classified_execution.test.mjs @@ -6,26 +6,22 @@ import test from "node:test"; const fixture = ` import assert from "node:assert/strict"; import { registerHooks } from "node:module"; -const sdk='export async function getSessionInfo(){} export function query(o){return globalThis.queryFixture(o);} export function startup(){throw new Error("unexpected");}'; -registerHooks({resolve(s,c,n){if(s==="@anthropic-ai/claude-agent-sdk")return {url:"data:text/javascript,"+encodeURIComponent(sdk),shortCircuit:true};return n(s,c);}}); -const {execute}=await import(${JSON.stringify(new URL("../dist/adapter.js",import.meta.url).href)}); -const mode=process.argv[1], events=[], abort=new AbortController(); -globalThis.queryFixture=({prompt,options})=>{ - const child=options.spawnClaudeCodeProcess({command:process.execPath,args:["-e","process.stdin.resume();process.stdin.on('end',()=>process.exit(1));"],env:process.env}); - return {close(){child.stdin.end();},async *[Symbol.asyncIterator](){ +import { sdkSource, startupFrom, runTurn } from ${JSON.stringify(new URL("./executor_run.mjs", import.meta.url).href)}; +registerHooks({resolve(s,c,n){if(s==="@anthropic-ai/claude-agent-sdk")return {url:"data:text/javascript,"+encodeURIComponent(sdkSource),shortCircuit:true};return n(s,c);}}); +const mode=process.argv[1], abort=new AbortController(); +globalThis.startupFixture=startupFrom(({prompt})=>({async *[Symbol.asyncIterator](){ const input=(await prompt[Symbol.asyncIterator]().next()).value; yield {type:"system",subtype:"init",session_id:"native",tools:[],mcp_servers:[]}; yield {type:"assistant",session_id:"native",parent_tool_use_id:mode==="child"?"child":null, uuid:"assistant",user_message_uuid:mode==="foreign-input"?"foreign":input.uuid,error:"authentication_failed",message:{content:[]}}; if(mode==="missing-result")throw new Error("unstructured 401 failure"); if(mode==="recovered")yield {type:"assistant",session_id:"native",parent_tool_use_id:null,uuid:"recovery",user_message_uuid:input.uuid,message:{content:[]}}; - if(mode==="cancel")abort.abort(); + if(mode==="cancel")await globalThis.cancelTurn(); yield {type:"result",uuid:"result",session_id:"native",user_message_uuid:input.uuid, subtype:mode==="recovered"?"success":mode==="budget"?"error_max_budget_usd":"error_during_execution", is_error:mode!=="recovered",result:"answer",usage:{input_tokens:2,output_tokens:1},modelUsage:{},total_cost_usd:0.01}; - }}; -}; -await execute({type:"start",input:[{content:[{type:"input_text",text:"hello"}]}],model:"fixture",system_prompt:"",cwd:process.cwd()},async e=>events.push(e),abort); +}}),{exitCode:mode==="cancel"?0:1}); +const {frames:events}=await runTurn({type:"executor_prepare",model:"fixture",system_prompt:"",cwd:process.cwd()},abort,[{content:[{type:"input_text",text:"hello"}]}]); const last=events.at(-1); // A nonzero native exit is expected on a structured provider rejection; the bridge // still retains its validated result and usage. Recovery + nonzero exit is generic. diff --git a/packages/claude-sdk-adapter/tests/execution.test.mjs b/packages/claude-sdk-adapter/tests/execution.test.mjs index 6ed59a70f..f1def15cc 100644 --- a/packages/claude-sdk-adapter/tests/execution.test.mjs +++ b/packages/claude-sdk-adapter/tests/execution.test.mjs @@ -2,85 +2,45 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import test from "node:test"; -// Exercise execute() against controlled SDK event interleavings. The native child -// is real, but these cases are not live provider acceptance. +// Exercise execute() against a controlled SDK event interleaving. The native child +// is real, but this case is not live provider acceptance. const fixture = ` import assert from "node:assert/strict"; import { registerHooks } from "node:module"; -const sdk = 'export async function getSessionInfo(){return {sessionId:"native"};} export function query(options){return globalThis.queryFixture(options);} export function startup(){throw new Error("Unexpected preparation");}'; +import { sdkSource, startupFrom, runTurn } from ${JSON.stringify(new URL("./executor_run.mjs", import.meta.url).href)}; registerHooks({ resolve(specifier, context, next) { - if (specifier === "@anthropic-ai/claude-agent-sdk") return {url:"data:text/javascript,"+encodeURIComponent(sdk),shortCircuit:true}; + if (specifier === "@anthropic-ai/claude-agent-sdk") return {url:"data:text/javascript,"+encodeURIComponent(sdkSource),shortCircuit:true}; return next(specifier,context); }}); -const { execute } = await import(${JSON.stringify(new URL("../dist/adapter.js", import.meta.url).href)}); -const { Inputs } = await import(${JSON.stringify(new URL("../dist/inputs.js", import.meta.url).href)}); -const { FunctionBridge } = await import(${JSON.stringify(new URL("../dist/function_bridge.js", import.meta.url).href)}); -const mode = process.argv[1]; -const events = []; -const inputs = new Inputs([{ content: [{ type: "input_text", text: "opening text" }] }]); const abort = new AbortController(); -let invocation; -let functions; -const emit = async event => { - events.push(event); - if (event.type === "input_ready") inputs.submit({type:"steer",input_id:"extra",input:[{content:[{type:"input_text",text:"later text"}]}]}); - if (mode === "later-function" && event.type === "usage" && !invocation) { - // The SDK dispatches MCP controls independently while the output consumer is - // yielding the previous native turn's result. This call belongs to the next turn. - invocation = functions.invoke({id:"call-next",name:"lookup",arguments:{}},abort.signal); - invocation.catch(() => {}); - } -}; -functions = new FunctionBridge(emit); -const init = {type:"system",subtype:"init",session_id:"native",tools:mode === "later-function"?["mcp__functions__lookup"]:[],mcp_servers:mode === "later-function"?[{name:"functions",status:"connected"}]:[]}; -const result = (uuid, ids, failed = false) => ({type:"result",uuid,session_id:"native",user_message_uuids:ids, - subtype:failed?"error_during_execution":"success",is_error:failed,result:"later answer", - usage:{input_tokens:2,output_tokens:1},modelUsage:{},total_cost_usd:0.01}); -globalThis.queryFixture = ({prompt,options}) => { +globalThis.startupFixture = startupFrom(({prompt, options}) => { assert.equal(options.effort, "high"); assert.deepEqual(options.thinking, {type:"enabled",budgetTokens:1024}); assert.equal(options.model, "fixture"); assert.equal(options.systemPrompt, "Translate."); assert.deepEqual(options.settings, {env:{CLAUDE_CODE_DISABLE_BACKGROUND_TASKS:"1",DISABLE_AUTOUPDATER:"1"}}); - const child = options.spawnClaudeCodeProcess({command:process.execPath,args:["-e","process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"],env:process.env,signal:abort.signal}); return { - close(){child.stdin.end();}, async *[Symbol.asyncIterator](){ const iterator = prompt[Symbol.asyncIterator](); const first = (await iterator.next()).value; - yield init; + yield {type:"system",subtype:"init",session_id:"native",tools:[],mcp_servers:[]}; const second = (await iterator.next()).value; - if(mode === "error-receipt") { yield result("failed",[first.uuid,second.uuid],true); return; } - yield result("first",[first.uuid]); - assert.ok(invocation,"next turn's control request did not reach the pending ledger"); - yield init; - functions.submit(JSON.stringify({type:"function_result",call_id:"call-next",delivery_id:"delivery",success:true,content:[{type:"input_text",text:"value"}]})); - await invocation; - yield {type:"user",session_id:"native",parent_tool_use_id:null,message:{role:"user",content:[{type:"tool_result",tool_use_id:"call-next",content:[{type:"text",text:"value"}],is_error:false}]}}; - yield result("second",[second.uuid]); + yield {type:"result",uuid:"failed",session_id:"native",user_message_uuids:[first.uuid,second.uuid],subtype:"error_during_execution",is_error:true, + result:"later answer",usage:{input_tokens:2,output_tokens:1},modelUsage:{},total_cost_usd:0.01}; }, }; -}; -await execute({type:"start",input: [{ content: [{ type: "input_text", text: "opening text" }] }],model:"fixture",system_prompt:"Translate.",cwd:process.cwd(),native_model_options:{effort:"high",thinking:{type:"enabled",budgetTokens:1024}}, - ...(mode === "later-function" ? {functions:[{name:"lookup",description:"fixture",parameters:{type:"object",properties:{}}}]} : {})},emit,abort,functions,inputs); -assert.equal(inputs.complete,true); +}); +const {frames:events} = await runTurn({type:"executor_prepare",model:"fixture",system_prompt:"Translate.",cwd:process.cwd(),native_model_options:{effort:"high",thinking:{type:"enabled",budgetTokens:1024}}}, + abort,[{ content: [{ type: "input_text", text: "opening text" }] }], + async (frame, turns) => { if (frame.type === "input_ready") await turns.submit({type:"steer",turn_id:"run",input_id:"extra",input:[{content:[{type:"input_text",text:"later text"}]}]}); }); assert.equal(events.filter(e=>e.type === "input_applied" && e.input_id === "extra").length,1); -if(mode === "error-receipt") { - assert.equal(events.at(-1).type,"error"); - assert.equal(events.at(-1).code,"execution_failed"); - assert.equal(events.filter(e=>e.type === "usage").length,1); - assert.equal(events.some(e=>e.type === "result"),false); -} else { - assert.equal(events.at(-1).type,"result"); - assert.equal(events.filter(e=>e.type === "function_applied").length,1); - assert.equal(events.filter(e=>e.type === "usage").length,2); - assert.equal(events.some(e=>e.type === "error"),false); -} +assert.equal(events.at(-1).type,"error"); +assert.equal(events.at(-1).code,"execution_failed"); +assert.equal(events.filter(e=>e.type === "usage").length,1); +assert.equal(events.some(e=>e.type === "result"),false); `; -for (const mode of ["error-receipt", "later-function"]) { - test(`native result lifecycle: ${mode}`, { timeout: 15000 }, () => { - const child = spawnSync(process.execPath, ["--input-type=module", "-e", fixture, mode], { encoding: "utf8", timeout: 10000 }); - assert.equal(child.status, 0, child.stderr || child.error?.message); - }); -} +test("a failed native result keeps usage and receipts without a result", { timeout: 15000 }, () => { + const child = spawnSync(process.execPath, ["--input-type=module", "-e", fixture], { encoding: "utf8", timeout: 10000 }); + assert.equal(child.status, 0, child.stderr || child.error?.message); +}); diff --git a/packages/claude-sdk-adapter/tests/executor.test.mjs b/packages/claude-sdk-adapter/tests/executor.test.mjs index 3e6083e44..a10f30b6c 100644 --- a/packages/claude-sdk-adapter/tests/executor.test.mjs +++ b/packages/claude-sdk-adapter/tests/executor.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import test from "node:test"; +import { assertFrame } from "./frames.mjs"; const fixture = ` import assert from "node:assert/strict"; @@ -70,7 +71,7 @@ async function launch(t,mode="normal") { const child=spawn(process.execPath,["--input-type=module","-e",fixture,mode],{stdio:["pipe","pipe","pipe","ipc"]}); const events=[],observations=[];let buffer="",stderr=""; child.stdout.setEncoding("utf8");child.stderr.setEncoding("utf8"); - child.stdout.on("data",data=>{buffer+=data;while(buffer.includes("\n")){const end=buffer.indexOf("\n");events.push(JSON.parse(buffer.slice(0,end)));buffer=buffer.slice(end+1)}}); + child.stdout.on("data",data=>{buffer+=data;while(buffer.includes("\n")){const end=buffer.indexOf("\n");{const event=JSON.parse(buffer.slice(0,end));assertFrame(event);events.push(event)}buffer=buffer.slice(end+1)}}); child.stderr.on("data",data=>{stderr+=data});child.on("message",event=>observations.push(event)); const closed=new Promise(resolve=>child.once("close",(code,signal)=>resolve({code,signal}))); t.after(async()=>{child.kill("SIGKILL");await closed}); diff --git a/packages/claude-sdk-adapter/tests/executor_run.mjs b/packages/claude-sdk-adapter/tests/executor_run.mjs new file mode 100644 index 000000000..592866583 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/executor_run.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { assertFrame } from "./frames.mjs"; + +// SDK module source for fixtures that run execute() in process: startup() and getSessionInfo() delegate to globals. +export const sdkSource = 'export async function getSessionInfo(...args){return globalThis.historyFixture?globalThis.historyFixture(...args):{sessionId:"native"};} export function startup(args){return globalThis.startupFixture(args);} export function query(){throw new Error("Unexpected direct query");}'; + +// Turns a controlled SDK stream, which reads `prompt` like the native Query, into the warm startup the Executor requires. +// The native child exits with exitCode once the Query closes. +export function startupFrom(queryFixture, { exitCode = 0, args } = {}) { + return async ({ options }) => { + const child = options.spawnClaudeCodeProcess({ command: process.execPath, env: options.env, signal: options.abortController.signal, + args: args ?? ["-e", `process.stdin.resume();process.stdin.on('end',()=>process.exit(${exitCode}));`] }); + const close = () => child.stdin.end(); + return { close, query: prompt => Object.assign(queryFixture({ prompt, options }), { + close, initializationResult: async () => ({ hooks_applied: true }), mcpServerStatus: async () => [], + interrupt: async () => ({ still_queued: [] }) }) }; + }; +} + +// Runs one Turn through execute(). Returns the frames in order, without the lifecycle frames of readiness and settlement. +// onFrame may call turns.submit() or globalThis.cancelTurn() while the Turn runs. +export async function runTurn(request, abort, input, onFrame = async () => {}) { + const { execute } = await import("../dist/adapter.js"); + const { ExecutorTurns } = await import("../dist/executor_protocol.js"); + const frames = []; + let settled; + const turns = new ExecutorTurns(async frame => { + assertFrame(frame); + if (frame.type === "turn_settled") settled = frame; + else if (frame.type !== "executor_ready" && frame.type !== "turn_started") { const { turn_id, ...rest } = frame; frames.push(rest); } + await onFrame(frame, turns); + }, abort); + globalThis.cancelTurn = () => turns.cancel({ type: "turn_cancel", turn_id: "run" }); + let ready; + const readied = new Promise(resolve => { ready = resolve; }); + let finished = false; + const done = execute(request, async frame => { assertFrame(frame); if (frame.type === "executor_ready") ready(); else frames.push(frame); }, abort, turns).finally(() => { finished = true; }); + await Promise.race([readied, done]); + if (finished) return { frames, settled }; + await turns.start({ type: "turn_start", turn_id: "run", input }); + await done; + return { frames, settled }; +} diff --git a/packages/claude-sdk-adapter/tests/frames.mjs b/packages/claude-sdk-adapter/tests/frames.mjs new file mode 100644 index 000000000..6033f8fda --- /dev/null +++ b/packages/claude-sdk-adapter/tests/frames.mjs @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +// The Go side reads the same file in apps/daemon/internal/agent/claudesdk/bridge_frames_test.go. +export const frames = JSON.parse(readFileSync(new URL("../fixtures/bridge-frames.json", import.meta.url), "utf8")); + +// A frame the bridge emits needs a fixture of its kind and may carry only fields the fixture lists. +export function assertFrame(frame, nested = ["message", "reasoning", "call", "withdrawn"]) { + const fixture = frames.from_bridge.find(candidate => candidate.type === frame.type); + assert.ok(fixture, `no fixture for bridge frame ${frame.type}`); + for (const key of Object.keys(frame)) assert.ok(key in fixture, `${frame.type}: field ${key} is not in the fixture`); + for (const key of nested) if (frame[key]) for (const field of Object.keys(frame[key])) assert.ok(field in fixture[key], `${frame.type}.${key}: field ${field} is not in the fixture`); +} diff --git a/packages/claude-sdk-adapter/tests/mcp.test.mjs b/packages/claude-sdk-adapter/tests/mcp.test.mjs index 75fcffe27..c26ae0326 100644 --- a/packages/claude-sdk-adapter/tests/mcp.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { parseStart } from "../dist/adapter.js"; +import { parseRequest } from "../dist/request.js"; import { MCPProfile } from "../dist/mcp.js"; import { MCPObserver } from "../dist/mcp_observer.js"; @@ -26,20 +26,20 @@ test("native selection composes unrestricted, selected and empty servers with ho }); test("invalid remote declarations and wildcard injection fail at the bridge boundary", () => { - const start = { type: "start", input: [{ content: [{ type: "input_text", text: "hello" }] }], model: "model", system_prompt: "", cwd: "/tmp" }; + const start = { type: "executor_prepare", model: "model", system_prompt: "", cwd: "/tmp" }; for (const value of [null, {}, [{ ...declaration(null), server_label: "functions" }], [{ ...declaration(null), server_url: "https://user:secret@example.invalid/mcp" }], [{ ...declaration(null), server_url: "https://example.invalid/mcp#fragment" }], [{ ...declaration(null), required: "true" }], [{ ...declaration(null), required: null }], [declaration(null), declaration([])]]) { - assert.throws(() => parseStart(JSON.stringify({ ...start, mcp_http_servers: value }))); + assert.throws(() => parseRequest(JSON.stringify({ ...start, mcp_http_servers: value }))); } - assert.deepEqual(parseStart(JSON.stringify({ ...start, mcp_http_servers: [declaration(null), { ...declaration([]), server_label: "empty" }] })).mcp_http_servers, + assert.deepEqual(parseRequest(JSON.stringify({ ...start, mcp_http_servers: [declaration(null), { ...declaration([]), server_label: "empty" }] })).mcp_http_servers, [declaration(null), { ...declaration([]), server_label: "empty" }]); const withQuery = { ...declaration(null), server_url: "https://example.invalid/mcp?tenant=a" }; - assert.deepEqual(parseStart(JSON.stringify({ ...start, mcp_http_servers: [withQuery] })).mcp_http_servers, [withQuery]); + assert.deepEqual(parseRequest(JSON.stringify({ ...start, mcp_http_servers: [withQuery] })).mcp_http_servers, [withQuery]); // Native normalization turns any tool name into a literal identity, so a wildcard cannot widen the allowlist. const wildcard = declaration(["*", "a/b"]); - assert.deepEqual(parseStart(JSON.stringify({ ...start, mcp_http_servers: [wildcard] })).mcp_http_servers, [wildcard]); + assert.deepEqual(parseRequest(JSON.stringify({ ...start, mcp_http_servers: [wildcard] })).mcp_http_servers, [wildcard]); assert.deepEqual(new MCPProfile([wildcard], []).allowed, [native("_"), native("a_b")]); }); diff --git a/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs b/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs index 531c9de70..f5dd1573d 100644 --- a/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs @@ -3,19 +3,19 @@ import { createServer } from "node:http"; import test from "node:test"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { parseStart } from "../dist/adapter.js"; +import { parseRequest } from "../dist/request.js"; import { MCPProfile } from "../dist/mcp.js"; const reference = "OAC_RUNTIME_MCP_BEARER_" + "A".repeat(26); const server = { server_label: "private", server_url: "https://example.invalid/mcp", allowed_tools: ["echo.v1"], bearer_token_env_var: reference }; -const start = servers => ({ type: "start", input: [{ content: [{ type: "input_text", text: "hello" }] }], model: "fixture", system_prompt: "", cwd: "/tmp", mcp_http_servers: servers }); +const start = servers => ({ type: "executor_prepare", model: "fixture", system_prompt: "", cwd: "/tmp", mcp_http_servers: servers }); test("bearer references remain literal in native configuration and private status is not retained", t => { const token = "fixture-private-bearer+/=="; process.env[reference] = token; t.after(() => { delete process.env[reference]; }); const declarations = [server, { server_label: "anonymous", server_url: "http://example.invalid/mcp", allowed_tools: [] }]; - const parsed = parseStart(JSON.stringify(start(declarations))); + const parsed = parseRequest(JSON.stringify(start(declarations))); const profile = new MCPProfile(parsed.mcp_http_servers, []); assert.equal(profile.servers.private.headers.Authorization, "Bearer ${" + reference + "}"); assert.equal(profile.servers.anonymous.headers.Authorization, ""); @@ -35,8 +35,8 @@ test("untrusted header expressions, raw tokens and non-HTTPS authenticated decla { ...server, headers: { "X-Tenant": 1 } }, { ...server, server_url: "http://example.invalid/mcp" }, ...[null, "", "ANTHROPIC_AUTH_TOKEN", "OAC_RUNTIME_MCP_BEARER_", "${OPERATOR_TOKEN}", reference + ":-fallback"].map(bearer_token_env_var => ({ ...server, bearer_token_env_var })), - ]) assert.throws(() => parseStart(JSON.stringify(start([invalid])))); - assert.throws(() => parseStart(JSON.stringify(start([server, { ...server, server_label: "other" }])))); + ]) assert.throws(() => parseRequest(JSON.stringify(start([invalid])))); + assert.throws(() => parseRequest(JSON.stringify(start([server, { ...server, server_label: "other" }])))); }); test("custom headers reach the native configuration beside the bearer", t => { diff --git a/packages/claude-sdk-adapter/tests/mcp_required.test.mjs b/packages/claude-sdk-adapter/tests/mcp_required.test.mjs index 0783044a8..fac4eae96 100644 --- a/packages/claude-sdk-adapter/tests/mcp_required.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_required.test.mjs @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { createServer } from "node:http"; import { join } from "node:path"; import test from "node:test"; +import { assertFrame } from "./frames.mjs"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; @@ -24,7 +25,7 @@ async function listen() { return { url: `http://127.0.0.1:${server.address().port}/mcp`, close: () => server.close() }; } -// Run the production entrypoint: its explicitly supplied Inputs must also be held. +// Run the production entrypoint: no input is released before required MCP readiness. const fixture = ` import assert from "node:assert/strict"; import { registerHooks } from "node:module"; @@ -89,20 +90,33 @@ for (const mode of ["connected", "pending", "failed", "missing", "duplicate", "m try { const servers = [{ server_label: "fixture", server_url: mode === "unlistable" ? "http://127.0.0.1:1/mcp" : remote.url, allowed_tools: mode === "workspace-empty" ? [] : ["echo"], required: mode !== "optional" }, { server_label: "optional", server_url: "https://optional.invalid/mcp", allowed_tools: [], required: false }]; - child.stdin.write(JSON.stringify({ type: "start", model: "fixed", input: [{ content: [{ type: "input_text", text: "one input" }] }], system_prompt: "", cwd, + const prepare = { type: "executor_prepare", model: "fixed", system_prompt: "", cwd, ...(mode.includes("history") || mode === "resume" ? { resume: "native" } : {}), - ...(mode.startsWith("workspace-") ? {workspace:{home:cwd,state:cwd,scratch:cwd,capability_root:cwd,env_names:[],mcp:servers}} : {mcp_http_servers:servers}) }) + "\n"); + ...(mode.startsWith("workspace-") ? {workspace:{home:cwd,state:cwd,scratch:cwd,capability_root:cwd,env_names:[],mcp:servers}} : {mcp_http_servers:servers}) }; + child.stdin.write(JSON.stringify(prepare) + "\n"); + // Readiness failures end the process before any input is accepted; otherwise one Turn runs. + const ready = ["connected", "resume", "optional", "workspace-connected", "workspace-empty", "wrong-history"].includes(mode); + if (ready) { + const until = Date.now() + 5000; + while (!stdout.includes("executor_ready") && Date.now() < until) await new Promise(resolve => setTimeout(resolve, 10)); + child.stdin.write(JSON.stringify({ type: "turn_start", turn_id: "run", input: [{ content: [{ type: "input_text", text: "one input" }] }] }) + "\n"); + while (!stdout.includes("turn_settled") && Date.now() < until) await new Promise(resolve => setTimeout(resolve, 10)); + child.stdin.end(); + } const exit = await closed; assert.equal(exit.signal, null, stderr); assert.equal(exit.code, 0, stderr); - const events = stdout.trim().split("\n").map(JSON.parse); + const events = stdout.trim().split("\n").filter(Boolean).map(JSON.parse); + events.forEach(event => assertFrame(event)); const success = ["connected", "resume", "optional", "workspace-connected", "workspace-empty"].includes(mode); - assert.equal(events.at(-1).type, success ? "result" : "error"); + assert.equal(events.some(e => e.type === "executor_ready"), ready); + assert.equal(events.some(e => e.type === "result"), success); + if (mode === "wrong-history") assert.ok(events.some(e => e.type === "error" && e.code === "execution_failed")); assert.equal(observations.filter(v => v.kind === "input").length, success || mode === "wrong-history" ? 1 : 0); - assert.equal(events.some(e => e.type === "prepared"), false); assert.equal(events.some(e => e.type === "input_ready"), success); + if (!ready) assert.equal(events.some(e => e.type === "turn_started"), false); if (mode === "missing-history") assert.equal(observations.length, 0); - if (mode === "cancelled") assert.equal(events.at(-1).code, "cancelled"); + if (!ready && mode !== "cancelled" && mode !== "missing-history") assert.ok(events.every(e => e.type === "error")); } finally { remote.close();clearTimeout(timer);child.kill("SIGKILL");await closed;rmSync(cwd, { recursive: true, force: true }); } diff --git a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs index b564306f0..a43f314eb 100644 --- a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { MCPProfile } from "../dist/mcp.js"; import { parseEnvironmentMCP } from "../dist/mcp_environment.js"; -import { immediateInput, parseStart } from "../dist/request.js"; +import { parseRequest } from "../dist/request.js"; import { WorkspaceProfile } from "../dist/workspace.js"; const stdio = { server_label: "installed", command: process.execPath, allowed_tools: null, @@ -24,16 +24,15 @@ function fixture(t, declarations = [stdio]) { const previous = process.env; process.env = { HOME: dirs.home, CLAUDE_CONFIG_DIR: dirs.state, ANTHROPIC_AUTH_TOKEN: "model-secret" }; t.after(() => { process.env = previous; rmSync(root, { recursive: true, force: true }); }); - const request = { type: "start", input: [{ content: [{ type: "input_text", text: "fixture" }] }], model: "fixture", system_prompt: "", cwd: dirs.work, workspace: config }; + const request = { type: "executor_prepare", model: "fixture", system_prompt: "", cwd: dirs.work, workspace: config }; return { dirs, config, request }; } test("installed MCP projection uses the common Runtime launcher", t => { const { request } = fixture(t); - assert.deepEqual(parseStart(JSON.stringify(request)), request); + assert.deepEqual(parseRequest(JSON.stringify(request)), request); const structured = { ...request, output_format: { type: "json_schema", schema: { type: "object" } } }; - assert.deepEqual(parseStart(JSON.stringify(structured)), structured); - assert.equal(immediateInput(request), undefined); + assert.deepEqual(parseRequest(JSON.stringify(structured)), structured); assert.deepEqual(parseEnvironmentMCP([stdio]), [stdio]); for (const value of [[stdio, stdio], [{ ...stdio, command: "relative" }], [{ ...stdio, env: { TOKEN: "secret" } }], [{ ...stdio, args: ["-c", "untrusted"] }], [{ ...stdio, allowed_tools: ["*"] }], @@ -129,7 +128,7 @@ test("MCP identity validation preserves host functions and ordinary child enviro test("public workspace HTTP preserves required startup and null versus empty allowlists", t => { const http = {server_label:"remote",server_url:"https://example.invalid/mcp",allowed_tools:[],required:true}; const {dirs,config,request}=fixture(t,[http]); - assert.deepEqual(parseStart(JSON.stringify(request)),request); + assert.deepEqual(parseRequest(JSON.stringify(request)),request); for(const allowed_tools of [null,[],["prove"]]) { const declaration={...http,allowed_tools}; assert.deepEqual(parseEnvironmentMCP([declaration]),[declaration]); diff --git a/packages/claude-sdk-adapter/tests/native_tools.test.mjs b/packages/claude-sdk-adapter/tests/native_tools.test.mjs index baa35cbb6..75d38a843 100644 --- a/packages/claude-sdk-adapter/tests/native_tools.test.mjs +++ b/packages/claude-sdk-adapter/tests/native_tools.test.mjs @@ -1,16 +1,22 @@ import assert from "node:assert/strict"; import { registerHooks } from "node:module"; import test from "node:test"; +import { sdkSource, startupFrom } from "./executor_run.mjs"; -const sdk = 'export async function getSessionInfo(){return {sessionId:"native"};} export function query(call){globalThis.options=call.options;return {close(){},async *[Symbol.asyncIterator](){}};} export function startup(){throw new Error("Unexpected preparation");}'; registerHooks({ resolve(specifier, context, next) { - if (specifier === "@anthropic-ai/claude-agent-sdk") return { url: "data:text/javascript," + encodeURIComponent(sdk), shortCircuit: true }; + if (specifier === "@anthropic-ai/claude-agent-sdk") return { url: "data:text/javascript," + encodeURIComponent(sdkSource), shortCircuit: true }; return next(specifier, context); }}); const { execute } = await import("../dist/adapter.js"); +const { ExecutorTurns } = await import("../dist/executor_protocol.js"); -const start = patch => ({ type: "start", input: [{ content: [{ type: "input_text", text: "hello" }] }], model: "model", system_prompt: "", cwd: process.cwd(), ...patch }); -const selected = async patch => { await execute(start(patch), async () => {}, new AbortController()); return globalThis.options; }; +const prepare = patch => ({ type: "executor_prepare", model: "model", system_prompt: "", cwd: process.cwd(), ...patch }); +const selected = async patch => { + const abort = new AbortController(); + globalThis.startupFixture = startupFrom(({ options }) => { globalThis.options = options; return { async *[Symbol.asyncIterator]() {} }; }); + await execute(prepare(patch), async () => {}, abort, new ExecutorTurns(async () => {}, abort)); + return globalThis.options; +}; test("environment none adds WebSearch, never WebFetch, when web search is live", async () => { for (const search of ["live", "disabled", undefined]) { diff --git a/packages/claude-sdk-adapter/tests/preparation.test.mjs b/packages/claude-sdk-adapter/tests/preparation.test.mjs index f5ba51ad3..c1c49f85a 100644 --- a/packages/claude-sdk-adapter/tests/preparation.test.mjs +++ b/packages/claude-sdk-adapter/tests/preparation.test.mjs @@ -4,7 +4,8 @@ import { mkdtempSync, mkdirSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { parseRequest, preparedInput } from "../dist/request.js"; +import { parseRequest } from "../dist/request.js"; +import { assertFrame } from "./frames.mjs"; // Exercise the packaged entrypoint and real child ownership with a controlled SDK. // Native dependency enforcement and model effects need separate native acceptance. @@ -44,7 +45,6 @@ globalThis.startupFixture = async ({options, initializeTimeoutMs}) => { async *[Symbol.asyncIterator](){ const command={type:"assistant",session_id:"native",parent_tool_use_id:null, message:{content:[{type:"tool_use",id:"command",name:"Bash",input:{command:"printf 'prepared'"}}]}}; - if(mode === "command-before-input")yield command; const iterator=prompt[Symbol.asyncIterator](); const first=await Promise.race([iterator.next(),closed.then(()=>({done:true}))]); if(first.done)return; @@ -80,23 +80,19 @@ function placement() { const dirs = Object.fromEntries(["workspace", "home", "state", "scratch", "deps"].map(name => { const path = join(root, name); mkdirSync(path); return [name, path]; })); - return { root, request: { type: "prepare", model: "fixed-model", system_prompt: "", cwd: dirs.workspace, + return { root, request: { type: "executor_prepare", model: "fixed-model", system_prompt: "", cwd: dirs.workspace, workspace: { home: dirs.home, state: dirs.state, scratch: dirs.scratch, env_names: [] } } }; } -test("prepare validates a workspace-only immutable configuration and prompt-only Start", () => { +test("prepare validates a workspace-only immutable configuration", () => { const { root, request } = placement(); try { assert.deepEqual(parseRequest(JSON.stringify(request)), request); assert.deepEqual(parseRequest(JSON.stringify({ ...request, functions: [] })), { ...request, functions: [] }); - for (const fields of [{ input: [{ content: [{ type: "input_text", text: "input" }] }] }, { workspace: undefined }, - { mcp_http_servers: [] }, { env: {} }, { resume: "" }, { type: "prepared" }]) { + for (const fields of [{ input: [{ content: [{ type: "input_text", text: "input" }] }] }, + { mcp_http_servers: [] }, { env: {} }, { resume: "" }, { type: "start" }]) { assert.throws(() => parseRequest(JSON.stringify({ ...request, ...fields })), /invalid_request/); } - assert.deepEqual(preparedInput({ type: "start", input: [{ content: [{ type: "input_text", text: "first" }] }] }), [{ content: [{ type: "input_text", text: "first" }] }]); - for (const value of [{ type: "start", input: [{ content: [] }] }, { type: "start", input: [{ content: [{ type: "input_text", text: "first" }] }], model: "other" }, - { type: "start", input: [{ content: [{ type: "input_text", text: "first" }] }], resume: "other" }, { type: "start", input: [{ content: [{ type: "input_text", text: "first" }] }], workspace: request.workspace }, - { type: "steer", text: "first" }, null]) assert.throws(() => preparedInput(value), /invalid_request/); } finally { rmSync(root, { recursive: true, force: true }); } }); @@ -111,7 +107,7 @@ async function launch(t, mode) { child.stdout.on("data", value => { buffer += value; while (buffer.includes("\n")) { - const end = buffer.indexOf("\n"); events.push(JSON.parse(buffer.slice(0, end))); buffer = buffer.slice(end + 1); + const end = buffer.indexOf("\n"); const event = JSON.parse(buffer.slice(0, end)); assertFrame(event); events.push(event); buffer = buffer.slice(end + 1); } notify(); }); @@ -130,72 +126,68 @@ async function launch(t, mode) { } finally { clearTimeout(timer); notify = () => {}; } }; const send = value => child.stdin.write(JSON.stringify(value) + "\n"); + const turn = text => send({ type: "turn_start", turn_id: "run", input: [{ content: [{ type: "input_text", text }] }] }); + const ready = () => wait(() => events.some(event => event.type === "executor_ready")); const finish = async code => { - await wait(() => events.some(event => event.type === "result" || event.type === "error")); + await wait(() => events.some(event => event.type === "turn_settled")); + child.stdin.end(); assert.deepEqual(await closed, { code: 0, signal: null }, stderr); - assert.equal(events.filter(event => event.type === "error" || event.type === "result").length, 1); - if (code) assert.deepEqual(events.at(-1), { type: "error", code }); - else assert.equal(events.at(-1).type, "result"); + const terminal = events.filter(event => event.type === "error" || event.type === "result"); + assert.equal(terminal.length, 1); + if (code) assert.equal(terminal[0].code, code); + else assert.equal(terminal[0].type, "result"); assert.equal(observations.filter(value => value.kind === "native_close").length, observations.filter(value => value.kind === "spawn").length); }; if (["missing-history", "resume", "resume-mismatch"].includes(mode)) request.resume = "native"; - return { child, request, events, observations, send, wait, finish }; + return { child, request, events, observations, closed, send, wait, turn, ready, finish }; } -for (const mode of ["release", "resume", "steer", "commands", "unused", "owner-cancel", "native-exit", "duplicate", "replacement", "early-steer", "resume-mismatch"]) { +for (const mode of ["resume", "steer", "commands", "resume-mismatch"]) { test(`prepared entrypoint lifecycle: ${mode}`, { timeout: 10000 }, async t => { - const bridge = await launch(t, mode); - const { child, request, events, observations, send, wait, finish } = bridge; + const { request, events, observations, send, wait, turn, ready, finish } = await launch(t, mode); send(request); - await wait(() => events.some(event => event.type === "prepared")); + await ready(); await new Promise(resolve => setTimeout(resolve, 30)); - assert.deepEqual(events, [{ type: "prepared" }]); assert.equal(observations.filter(value => value.kind === "spawn").length, 1); assert.equal(observations.some(value => value.kind === "input"), false); - if (mode === "unused") { child.stdin.end(); await finish("cancelled"); } - else if (mode === "owner-cancel") { child.kill("SIGTERM"); await finish("cancelled"); } - else if (mode === "native-exit") await finish("execution_failed"); - else if (mode === "replacement") { send({ type: "start", input: [{ content: [{ type: "input_text", text: "first" }] }], model: "changed" }); await finish("invalid_request"); } - else if (mode === "early-steer") { send({ type: "steer", input_id: "early", input: [{ content: [{ type: "input_text", text: "first" }] }] }); await finish("invalid_request"); } - else { - send({ type: "start", input: [{ content: [{ type: "input_text", text: "first" }] }] }); - if (mode === "duplicate") { send({ type: "start", input: [{ content: [{ type: "input_text", text: "second" }] }] }); await finish("invalid_request"); } - else if (mode === "resume-mismatch") { - await finish("execution_failed"); - assert.equal(events.some(event => event.type === "input_ready"), false); - } else { - await wait(() => events.some(event => event.type === "input_ready")); - if (mode === "steer") send({ type: "steer", input_id: "extra", input: [{ content: [{ type: "input_text", text: "second" }] }] }); - await finish(); - assert.deepEqual(observations.filter(value => value.kind === "input"), [{ kind: "input", text: "first" }]); - if (mode === "steer") assert.ok(events.some(event => event.type === "input_applied" && event.input_id === "extra")); - if (mode === "resume") assert.equal(observations[0].kind, "history"); - if (mode === "commands") assert.deepEqual(events.filter(event=>event.type === "command_observation").map(event=> - [event.session_id,event.id,event.stage,event.observation.output]), - [["native","command","before",undefined],["native","command","after","prepared"]]); - } + turn("first"); + if (mode === "resume-mismatch") { + await finish("execution_failed"); + assert.equal(events.some(event => event.type === "input_ready"), false); + return; } + await wait(() => events.some(event => event.type === "input_ready")); + if (mode === "steer") send({ type: "steer", turn_id: "run", input_id: "extra", input: [{ content: [{ type: "input_text", text: "second" }] }] }); + await finish(); + assert.deepEqual(observations.filter(value => value.kind === "input"), [{ kind: "input", text: "first" }]); + if (mode === "steer") assert.ok(events.some(event => event.type === "input_applied" && event.input_id === "extra")); + if (mode === "resume") assert.equal(observations[0].kind, "history"); + if (mode === "commands") assert.deepEqual(events.filter(event => event.type === "command_observation").map(event => + [event.session_id, event.id, event.stage, event.observation.output]), + [["native", "command", "before", undefined], ["native", "command", "after", "prepared"]]); }); } -test("preparation cannot observe commands before actual input", { timeout: 10000 }, async t => { - const { request, events, observations, send, finish } = await launch(t, "command-before-input"); - send(request); - await finish("execution_failed"); - assert.equal(events.some(event=>event.type === "command_observation"),false); - assert.equal(observations.some(value=>value.kind === "input"),false); +test("an owner signal or a lost native process ends an idle Executor", { timeout: 10000 }, async t => { + for (const mode of ["owner-cancel", "native-exit"]) { + const { child, request, events, closed, send, ready } = await launch(t, mode); + send(request); + await ready(); + if (mode === "owner-cancel") child.kill("SIGTERM"); + assert.deepEqual(await closed, { code: 0, signal: null }); + assert.equal(events.some(event => event.type === "error"), mode === "native-exit"); + } }); -for (const mode of ["missing-history", "startup-error", "early-start", "cancel-startup"]) { - test(`preparation rejects before receipt: ${mode}`, { timeout: 10000 }, async t => { - const { child, request, events, observations, send, wait, finish } = await launch(t, mode === "cancel-startup" ? "slow-startup" : mode); +for (const mode of ["missing-history", "startup-error", "cancel-startup"]) { + test(`preparation fails before readiness: ${mode}`, { timeout: 10000 }, async t => { + const { child, request, events, observations, closed, send, wait } = await launch(t, mode === "cancel-startup" ? "slow-startup" : mode); send(request); - if (mode === "early-start") send({ type: "start", input: [{ content: [{ type: "input_text", text: "too early" }] }] }); if (mode === "cancel-startup") { await wait(() => observations.some(value => value.kind === "spawn")); child.kill("SIGTERM"); } - await finish(mode === "missing-history" ? "history_unavailable" : mode === "early-start" ? "invalid_request" : mode === "cancel-startup" ? "cancelled" : "execution_failed"); - assert.equal(events.some(event => event.type === "prepared"), false); + assert.deepEqual(await closed, { code: 0, signal: null }); + assert.equal(events.some(event => event.type === "executor_ready"), false); + assert.deepEqual(events.map(event => event.code), mode === "missing-history" ? ["history_unavailable"] : mode === "startup-error" ? ["execution_failed"] : []); assert.equal(observations.some(value => value.kind === "input"), false); if (mode === "missing-history") assert.equal(observations.some(value => value.kind === "spawn"), false); }); } - diff --git a/packages/claude-sdk-adapter/tests/structured_output.test.mjs b/packages/claude-sdk-adapter/tests/structured_output.test.mjs index e38e420c9..97d2ef0da 100644 --- a/packages/claude-sdk-adapter/tests/structured_output.test.mjs +++ b/packages/claude-sdk-adapter/tests/structured_output.test.mjs @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { StructuredOutput } from "../dist/structured_output.js"; -import { parseStart } from "../dist/request.js"; +import { parseRequest } from "../dist/request.js"; const call = (id, extra={}) => ({type:'assistant', session_id:'session', parent_tool_use_id:null, message:{content:[{type:'tool_use',id,name:'StructuredOutput',input:{}}]},...extra}); const receipt = (id,error=false) => ({type:'user',session_id:'session',parent_tool_use_id:null,message:{content:[{type:'tool_result',tool_use_id:id,is_error:error}]}}); const result = {type:'result',subtype:'success',is_error:false,structured_output:{number:9007199254740992},result:'{"number":9007199254740993}'}; @@ -22,8 +22,8 @@ test('unrelated or failed results cannot publish a candidate',()=>{ } }); test('output configuration combines with agents and MCP',()=>{ - const r={type:'start',input: [{ content: [{ type: "input_text", text: 'hello' }] }],model:'model',system_prompt:'',cwd:'/tmp',output_format:{type:'json_schema',schema:{type:'object'}}}; - assert.deepEqual(parseStart(JSON.stringify(r)),r); - for(const change of [{subagents:{max_concurrent:1}},{mcp_http_servers:[]}]) assert.deepEqual(parseStart(JSON.stringify({...r,...change})),{...r,...change}); - for(const change of [{output_format:{type:'text',schema:{}}}]) assert.throws(()=>parseStart(JSON.stringify({...r,...change}))); + const r={type:'executor_prepare',model:'model',system_prompt:'',cwd:'/tmp',output_format:{type:'json_schema',schema:{type:'object'}}}; + assert.deepEqual(parseRequest(JSON.stringify(r)),r); + for(const change of [{subagents:{max_concurrent:1}},{mcp_http_servers:[]}]) assert.deepEqual(parseRequest(JSON.stringify({...r,...change})),{...r,...change}); + for(const change of [{output_format:{type:'text',schema:{}}}]) assert.throws(()=>parseRequest(JSON.stringify({...r,...change}))); }); diff --git a/packages/claude-sdk-adapter/tests/tool_search.test.mjs b/packages/claude-sdk-adapter/tests/tool_search.test.mjs index f33da1e65..c657d4b9f 100644 --- a/packages/claude-sdk-adapter/tests/tool_search.test.mjs +++ b/packages/claude-sdk-adapter/tests/tool_search.test.mjs @@ -1,19 +1,19 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { parseStart } from "../dist/request.js"; +import { parseRequest } from "../dist/request.js"; -const request = () => ({type:"start", input:[{content:[{type:"input_text",text:"lookup"}]}], model:"model", system_prompt:"", cwd:process.cwd(), +const request = () => ({type:"executor_prepare", model:"model", system_prompt:"", cwd:process.cwd(), tool_search:true, functions:[{name:"lookup",description:"Lookup",parameters:{type:"object"},defer_loading:true}]}); test("discovery preserves intent and rejects a mismatch with deferred functions", () => { - assert.deepEqual(parseStart(JSON.stringify(request())), request()); + assert.deepEqual(parseRequest(JSON.stringify(request())), request()); for (const patch of [{tool_search:false}, {tool_search:null}, {functions:[{...request().functions[0],defer_loading:false}]}]) { - assert.throws(() => parseStart(JSON.stringify({...request(),...patch})), /invalid_request/); + assert.throws(() => parseRequest(JSON.stringify({...request(),...patch})), /invalid_request/); } }); test("discovery combines with structured output and MCP", () => { const combined = {...request(), output_format:{type:"json_schema",schema:{type:"object"}}, mcp_http_servers:[{server_label:"docs",server_url:"https://example.test/mcp",allowed_tools:null}]}; - assert.deepEqual(parseStart(JSON.stringify(combined)), combined); + assert.deepEqual(parseRequest(JSON.stringify(combined)), combined); }); diff --git a/packages/claude-sdk-adapter/tests/workspace.test.mjs b/packages/claude-sdk-adapter/tests/workspace.test.mjs index 75b964ed1..98939e4d2 100644 --- a/packages/claude-sdk-adapter/tests/workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace.test.mjs @@ -3,7 +3,7 @@ import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSyn import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { parseStart } from "../dist/adapter.js"; +import { parseRequest } from "../dist/request.js"; import { parseWorkspace, WorkspaceProfile } from "../dist/workspace.js"; function fixture(t) { @@ -15,7 +15,7 @@ function fixture(t) { })); const config = { home: dirs.home, state: dirs.state, scratch: dirs.scratch, env_names: ["ANTHROPIC_API_KEY", "HTTP_PROXY"] }; - const request = { type: "start", input: [{ content: [{ type: "input_text", text: "fixture" }] }], model: "fixture", system_prompt: "", cwd: dirs.workspace, workspace: config }; + const request = { type: "executor_prepare", model: "fixture", system_prompt: "", cwd: dirs.workspace, workspace: config }; const previous = process.env; process.env = { HOME: dirs.home, CLAUDE_CONFIG_DIR: dirs.state, ANTHROPIC_API_KEY: "fixture-secret", HTTP_PROXY: "http://fixture-proxy", UNSELECTED_CANARY: "must-not-inherit", NODE_OPTIONS: "unsafe", @@ -26,19 +26,19 @@ function fixture(t) { test("workspace is explicit, typed and rejects external MCP declarations", t => { const { config, request } = fixture(t); - assert.deepEqual(parseStart(JSON.stringify(request)), request); + assert.deepEqual(parseRequest(JSON.stringify(request)), request); for (const fields of [{ mcp_http_servers: [] }, { functions: null }, { mcp_http_servers: null }]) { - assert.throws(() => parseStart(JSON.stringify({ ...request, ...fields })), /invalid_request/); + assert.throws(() => parseRequest(JSON.stringify({ ...request, ...fields })), /invalid_request/); } for (const workspace of [null, [], {}, { ...config, native: {} }, { ...config, env: {} }, { ...config, env_names: ["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY"] }, { ...config, env_names: ["NODE_OPTIONS"] }, { ...config, env_names: ["HOME"] }]) { - assert.throws(() => parseStart(JSON.stringify({ ...request, workspace })), /invalid_request/); + assert.throws(() => parseRequest(JSON.stringify({ ...request, workspace })), /invalid_request/); } const { workspace, ...ordinary } = request; - assert.deepEqual(parseStart(JSON.stringify(ordinary)), ordinary); + assert.deepEqual(parseRequest(JSON.stringify(ordinary)), ordinary); assert.equal(parseWorkspace(undefined, ordinary.cwd), undefined); - assert.deepEqual(parseStart(JSON.stringify({ ...ordinary, functions: [], mcp_http_servers: [] })), + assert.deepEqual(parseRequest(JSON.stringify({ ...ordinary, functions: [], mcp_http_servers: [] })), { ...ordinary, functions: [], mcp_http_servers: [] }); }); @@ -95,7 +95,7 @@ test("Core declarations remove subagent and search tools only when declared", t test("workspace functions are parsed", async t => { const { request } = fixture(t); const functions = [{ name: "lookup", description: "Lookup", parameters: { type: "object" } }]; - assert.deepEqual(parseStart(JSON.stringify({ ...request, functions })).functions, functions); + assert.deepEqual(parseRequest(JSON.stringify({ ...request, functions })).functions, functions); }); test("initialized environment is ordinary child environment on every layout", t => { @@ -119,11 +119,11 @@ test("workspace discovery keeps the function boundary on the native tool set", t const { request } = fixture(t); const functions = [{name:"lookup", description:"Lookup", parameters:{type:"object"}, defer_loading:true}]; const configured = {...request, tool_search:true, functions}; - assert.deepEqual(parseStart(JSON.stringify(configured)), configured); + assert.deepEqual(parseRequest(JSON.stringify(configured)), configured); }); test("web search control is the disabled or live mode", t => { const { request } = fixture(t); - assert.equal(parseStart(JSON.stringify({ ...request, web_search: "live" })).web_search, "live"); - assert.throws(() => parseStart(JSON.stringify({ ...request, web_search: true })), /invalid_request/); + assert.equal(parseRequest(JSON.stringify({ ...request, web_search: "live" })).web_search, "live"); + assert.throws(() => parseRequest(JSON.stringify({ ...request, web_search: true })), /invalid_request/); }); diff --git a/packages/claude-sdk-adapter/tests/workspace_execution.test.mjs b/packages/claude-sdk-adapter/tests/workspace_execution.test.mjs index 00d29ab6d..c849d84f5 100644 --- a/packages/claude-sdk-adapter/tests/workspace_execution.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace_execution.test.mjs @@ -8,12 +8,11 @@ import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "nod import { registerHooks } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; -const sdk = 'export function getSessionInfo(...args){return globalThis.historyFixture(...args);} export function query(options){return globalThis.queryFixture(options);} export function startup(){throw new Error("Unexpected preparation");}'; +import { sdkSource, startupFrom, runTurn } from ${JSON.stringify(new URL("./executor_run.mjs", import.meta.url).href)}; registerHooks({ resolve(specifier, context, next) { - if (specifier === "@anthropic-ai/claude-agent-sdk") return {url:"data:text/javascript,"+encodeURIComponent(sdk),shortCircuit:true}; + if (specifier === "@anthropic-ai/claude-agent-sdk") return {url:"data:text/javascript,"+encodeURIComponent(sdkSource),shortCircuit:true}; return next(specifier,context); }}); -const { execute } = await import(${JSON.stringify(new URL("../dist/adapter.js", import.meta.url).href)}); const root = realpathSync(mkdtempSync(join(tmpdir(), "oac-workspace-execute-"))); const dirs = Object.fromEntries(["workspace", "home", "state", "scratch", "deps", "capability"].map(name => { const path = join(root, name); mkdirSync(path); return [name, path]; @@ -24,7 +23,7 @@ writeFileSync(join(dirs.capability, "pkg", "skill", "SKILL.md"), "---\\nname: fi const workspace = { home: dirs.home, state: dirs.state, scratch: dirs.scratch, env_names: ["ANTHROPIC_API_KEY"], ...(mode.startsWith("skills") ? { capability_root: dirs.capability, skills: [{ metadata: { type: "inline", name: "fixture", description: "Fixture." }, relative_root: "pkg/skill", package_root: "pkg" }] } : {}) }; -const request = { type: "start", input: [{ content: [{ type: "input_text", text: "fixture" }] }], model: "fixture", system_prompt: mode === "instructions" ? "Be brief." : "", cwd: dirs.workspace, +const request = { type: "executor_prepare", model: "fixture", system_prompt: mode === "instructions" ? "Be brief." : "", cwd: dirs.workspace, workspace, ...(mode.startsWith("resume") ? { resume: "native" } : {}), ...(mode.startsWith("functions") ? { functions: [{ name: "lookup", description: "Lookup", parameters: { type: "object" }, defer_loading: false }] } : {}) }; process.env.HOME = dirs.home; @@ -42,7 +41,7 @@ globalThis.historyFixture = async (id, options) => { assert.equal(process.env.CLAUDE_CONFIG_DIR, dirs.state); return mode === "resume-missing" ? undefined : { sessionId: "native" }; }; -globalThis.queryFixture = ({prompt, options}) => { +globalThis.startupFixture = startupFrom(({prompt, options}) => { calls.push("query"); assert.equal(options.env.UNSELECTED_CANARY, "must-not-inherit"); assert.equal(options.env.ANTHROPIC_API_KEY, "fixture-secret"); @@ -53,9 +52,7 @@ globalThis.queryFixture = ({prompt, options}) => { assert.deepEqual(options.settings, {env:{CLAUDE_CODE_DISABLE_BACKGROUND_TASKS:"1",DISABLE_AUTOUPDATER:"1"}}); assert.deepEqual(options.systemPrompt, {type:"preset",preset:"claude_code",...(mode === "instructions" ? {append:"Be brief."} : {})}); assert.equal(options.resume, request.resume); - const child = options.spawnClaudeCodeProcess({ command: process.execPath, env: options.env, signal: abort.signal, - args: ["-e", "const assert=require('node:assert/strict');assert.equal(process.env.UNSELECTED_CANARY,'must-not-inherit');assert.equal(process.env.ANTHROPIC_API_KEY,'fixture-secret');process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"] }); - return { close() { child.stdin.end(); }, async *[Symbol.asyncIterator]() { + return { async *[Symbol.asyncIterator]() { const first = (await prompt[Symbol.asyncIterator]().next()).value; yield { type: "system", subtype: "init", session_id: "native", mcp_servers: request.functions ? [{ name: "functions", status: "connected" }] : [], tools: ["Bash", "Read", "Edit", ...(mode === "functions" ? ["mcp__functions__lookup"] : []), ...(mode.startsWith("skills") ? ["Skill"] : [])], @@ -69,19 +66,20 @@ globalThis.queryFixture = ({prompt, options}) => { message:{content:[{type:"tool_result",tool_use_id:"observed",content:"observed",is_error:false}]}, tool_use_result:{stdout:"observed",stderr:"",interrupted:false}}; if (mode !== "commands-success") yield call("unfinished"); - if (mode === "commands-cancel") {abort.abort();return;} + if (mode === "commands-cancel") {await globalThis.cancelTurn();return;} } yield { type: "result", uuid: "result", session_id: "native", user_message_uuids: [first.uuid], subtype: "success", is_error: false, result: "fixture", usage: {input_tokens:1,output_tokens:1}, modelUsage:{} }; } }; -}; +}, { args: ["-e", "const assert=require('node:assert/strict');assert.equal(process.env.UNSELECTED_CANARY,'must-not-inherit');assert.equal(process.env.ANTHROPIC_API_KEY,'fixture-secret');process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"] }); +const input = [{ content: [{ type: "input_text", text: "fixture" }] }]; try { if (mode === "resume-mismatch") { process.env.CLAUDE_CONFIG_DIR = dirs.home; - await assert.rejects(execute(request, async e => events.push(e), abort), /invalid_request/); + await assert.rejects(runTurn(request, abort, input), /invalid_request/); assert.deepEqual(calls, []); } else { - await execute(request, async e => events.push(e), abort); + events.push(...(await runTurn(request, abort, input)).frames); if (mode === "resume-missing") { assert.deepEqual(calls, ["history"]); assert.deepEqual(events, [{type:"error",code:"history_unavailable"}]);