From 2a54f831555ed4f018af78584ba21f76854b6056 Mon Sep 17 00:00:00 2001 From: dn00 Date: Wed, 10 Jun 2026 13:49:31 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20tier-2=20parity=20=E2=80=94=20error?= =?UTF-8?q?=5Fmax=5Fturns,=20control=20acks,=20invalid-model=20shape,=20th?= =?UTF-8?q?inking=20signature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the consumed-field parity gaps confirmed by replay against the 2026-06-09 goldens (PARITY-GAPS #2/#3/#14/#15/#16), and adds the parity-policy tiers to PARITY-GAPS.md (contract / consumed fields / on-demand / won't-fix). - --max-turns now counts assistant API rounds (SSE message_start) like native, not user prompts — a single prompt chaining tools can exhaust it. On exceeding: interrupt, then report result.error_max_turns (is_error, NO result field, num_turns = rounds) and exit 1, even in stream-json mode, whether or not the interrupt beat the turn's natural completion. The old prompt-count check remains as a fallback for backends without SSE. - Ack initialize (truthful payload: commands from transcript init, output_style, pid) and set_permission_mode (echo mode) using native's nested control_response shape {type, response: {subtype, request_id, response}}; migrated the interrupt ack from clarp's old flat shape. - Turn-level backend API errors report subtype "success" with is_error: true (invalid-model golden), not subtype "error"; emitResult decouples is_error from subtype and can omit the result field (error_max_turns has none). - Assembler accumulates signature_delta; thinking blocks are {type, thinking, signature} like native. - Exit code now reflects the last result's is_error on stdin-drain shutdown (native exits 1 after an invalid-model turn even though the stream ends normally). --- goldens/PARITY-GAPS.md | 57 +++++++++---- src/message-assembler.test.ts | 21 ++++- src/message-assembler.ts | 12 ++- src/output.ts | 30 ++++--- src/session.test.ts | 147 ++++++++++++++++++++++++++++++++-- src/session.ts | 112 ++++++++++++++++++++++++-- 6 files changed, 337 insertions(+), 42 deletions(-) diff --git a/goldens/PARITY-GAPS.md b/goldens/PARITY-GAPS.md index 0cb260e..4ccd6a8 100644 --- a/goldens/PARITY-GAPS.md +++ b/goldens/PARITY-GAPS.md @@ -5,6 +5,29 @@ against what clarp's `src/output.ts` / `src/message-assembler.ts` emit. Ordered by likely impact on SDK-compatible consumers. Event shapes cited from the golden `native.stdout.jsonl` files — read those, not memory, when fixing. +## Parity policy + +clarp targets **contract parity**, not 100% literal parity. Tiers: + +- **Tier 1 — lifecycle contract (always 100%):** exactly one terminal + `result`, correct subtype/`is_error`/exit code, turn structure, + `can_use_tool` round-trip, interrupts, never hangs silently. +- **Tier 2 — consumed fields (100%, finite list):** fields/acks real SDK + clients read or block on: `error_max_turns`, control handshake acks, + `stop_reason`, tool ids, thinking `signature`. +- **Tier 3 — shape completeness (on demand):** envelope `uuid`/`request_id`, + full `usage` richness, `rate_limit_event` shape, `stream_event` counts, + `system.task_started`/`task_notification` (observed in the task-subagent + golden). Closed when a real consumer breaks on one. +- **Tier 4 — won't fix, by design:** values clarp cannot truthfully produce. + Fabricating them would mislead consumers, so they are omitted instead: + `total_cost_usd` (no metered cost exists on a subscription), `ttft_ms` + (clarp's vantage differs), `thinking_tokens` estimates, the rich + `initialize` ack payload (account/models/agents — clarp acks with what it + truthfully observes: commands, output_style, pid), applying + `set_permission_mode` to the hidden TUI (acked for protocol compatibility; + permission decisions flow through `can_use_tool`/auto-deny). + ## High impact — consumers parse these 1. **`result` field names and richness.** Native `result.success` carries @@ -15,15 +38,15 @@ the golden `native.stdout.jsonl` files — read those, not memory, when fixing. clarp emits `cost_usd` (wrong name — native is `total_cost_usd`) and has none of the rest. Golden: `text-multi-turn/native.stdout.jsonl`. -2. **Missing result subtype `error_max_turns`.** Native exits code 1 with - `subtype: "error_max_turns"`, `errors: [...]`, no `result` field, when - `--max-turns` is exhausted. clarp has no such subtype anywhere. +2. **Missing result subtype `error_max_turns`.** ✅ FIXED 2026-06-10. clarp now + ends the session with `error_max_turns` (is_error, no `result` field) and + exit 1 when `--max-turns` is exceeded, instead of interrupting and + continuing with `result.success`. Golden: `max-turns-exhaustion/native.stdout.jsonl`. -3. **`thinking` blocks lose `signature`.** Native thinking blocks are - `{type, thinking, signature}`; clarp's assembler ignores `signature_delta` - so its thinking blocks have no signature. Consumers that replay assistant - content to the API need the signature. Golden: `thinking/native.stdout.jsonl`. +3. **`thinking` blocks lose `signature`.** ✅ FIXED 2026-06-10. The assembler + accumulates `signature_delta`; thinking blocks are `{type, thinking, + signature}` like native. Golden: `thinking/native.stdout.jsonl`. 4. **`assistant` event envelope.** Native: `uuid`, `request_id`, `parent_tool_use_id` at top level; `message` includes `id`, `type: @@ -107,16 +130,20 @@ recorded native summaries (replay run: `.parity-runs/2026-06-10T06-19-26.162Z`). without it clarp auto-denies via the pty so the turn completes with the model's explanation (native's deny-by-default behavior). -14. **Missing control acks.** Native answers `initialize` (with a rich - capabilities payload — commands list, etc.) and `set_permission_mode` - (`{"mode": ...}`) with `control_response` events; clarp answered neither. +14. **Missing control acks.** ✅ FIXED 2026-06-10. clarp acks `initialize` + (with a truthful payload: commands from the transcript init, output_style, + pid — not native's full account/models capabilities, see tier 4) and + `set_permission_mode` (echoing `{mode}`), using native's nested shape + `{type, response: {subtype, request_id, response}}`. The interrupt ack was + also migrated from clarp's old flat shape to the nested one. -15. **`--max-turns` not enforced.** Native stops after N turns with - `result.error_max_turns` (exit 1); clarp kept going (4 extra assistant - events) and reported `result.success` (exit 0). +15. **`--max-turns` not enforced.** ✅ FIXED 2026-06-10. See #2: clarp now + emits `error_max_turns` and exits 1 instead of continuing. -16. **Invalid model divergence.** Native reports a *success* result whose - text explains the model problem; clarp reports `result.error`. +16. **Invalid model divergence.** ✅ FIXED 2026-06-10. Turn-level backend API + errors now report subtype `success` with `is_error: true` (matching the + invalid-model golden) instead of subtype `error`; `emitResult` decouples + `is_error` from subtype to allow this. 17. **`stream_event` undercount.** With `--include-partial-messages`, clarp emitted 4 fewer `stream_event`s than native across two prompts — diff --git a/src/message-assembler.test.ts b/src/message-assembler.test.ts index 2c33933..73307dc 100644 --- a/src/message-assembler.test.ts +++ b/src/message-assembler.test.ts @@ -49,7 +49,7 @@ describe("MessageAssembler", () => { expect(msgs).toHaveLength(2); expect(msgs[0]!.content).toHaveLength(1); - expect(msgs[0]!.content[0]).toEqual({ type: "thinking", thinking: "Let me think..." }); + expect(msgs[0]!.content[0]).toEqual({ type: "thinking", thinking: "Let me think...", signature: "" }); expect(msgs[1]!.content).toHaveLength(1); expect(msgs[1]!.content[0]).toEqual({ type: "text", text: "Answer" }); }); @@ -283,3 +283,22 @@ describe("MessageAssembler", () => { expect(msgs[0]!.content[0]).toEqual({ type: "text", text: "Hi" }); }); }); + +describe("thinking signature", () => { + it("accumulates signature_delta into the thinking block like native", () => { + const msgs = collect([ + sse({ type: "message_start", message: { id: "msg_sig", model: "m", content: [] } }), + sse({ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }), + sse({ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "hmm" } }), + sse({ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "AbC" } }), + sse({ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "dEf" } }), + sse({ type: "content_block_stop", index: 0 }), + sse({ type: "message_stop" }), + ]); + + expect(msgs).toHaveLength(1); + // Native thinking blocks are {type, thinking, signature} — consumers that + // replay assistant content to the API need the signature intact. + expect(msgs[0]!.content[0]).toEqual({ type: "thinking", thinking: "hmm", signature: "AbCdEf" }); + }); +}); diff --git a/src/message-assembler.ts b/src/message-assembler.ts index affd1f5..4b14233 100644 --- a/src/message-assembler.ts +++ b/src/message-assembler.ts @@ -2,7 +2,7 @@ import type { SSEEvent } from "./proxy.js"; export type ContentBlock = | { type: "text"; text: string } - | { type: "thinking"; thinking: string } + | { type: "thinking"; thinking: string; signature: string } | { type: "tool_use"; id: string; name: string; input: unknown } | { type: "tool_result"; tool_use_id: string; content: unknown }; @@ -114,7 +114,13 @@ export class MessageAssembler { if (block.type === "text") { this.current.content.push({ type: "text", text: (block.text as string) || "" }); } else if (block.type === "thinking") { - this.current.content.push({ type: "thinking", thinking: (block.thinking as string) || "" }); + // Native thinking blocks carry a signature (needed by consumers that + // replay assistant content to the API); it streams via signature_delta. + this.current.content.push({ + type: "thinking", + thinking: (block.thinking as string) || "", + signature: (block.signature as string) || "", + }); } else if (block.type === "tool_use") { this.current.content.push({ type: "tool_use", @@ -142,6 +148,8 @@ export class MessageAssembler { block.text += (delta.text as string) || ""; } else if (delta.type === "thinking_delta" && block.type === "thinking") { block.thinking += (delta.thinking as string) || ""; + } else if (delta.type === "signature_delta" && block.type === "thinking") { + block.signature += (delta.signature as string) || ""; } else if ( delta.type === "input_json_delta" && (block.type === "tool_use" || !KNOWN_BLOCK_TYPES.has(block.type)) diff --git a/src/output.ts b/src/output.ts index 9faf0e8..3afa6c7 100644 --- a/src/output.ts +++ b/src/output.ts @@ -305,15 +305,20 @@ export function emitControlRequest(requestId: string, toolName: string, toolUseI } /** - * Emits the immediate success acknowledgement native claude -p writes for - * accepted control requests such as interrupt. + * Emits the success acknowledgement native claude -p writes for accepted + * control requests (interrupt, initialize, set_permission_mode, ...). + * Native nests request_id and any payload inside `response`: + * {type, response: {subtype: "success", request_id, response: {...}}} */ -export function emitControlResponseSuccess(requestId?: string): void { +export function emitControlResponseSuccess(requestId?: string, payload?: Record): void { if (format !== "stream-json") return; writeLine({ type: "control_response", - ...(requestId ? { request_id: requestId } : {}), - response: { subtype: "success" }, + response: { + subtype: "success", + ...(requestId ? { request_id: requestId } : {}), + ...(payload ? { response: payload } : {}), + }, }); } @@ -352,8 +357,8 @@ export function emitPostTurnSummary(opts: { * Emits the final result for a turn or process exit. */ export function emitResult( - subtype: "success" | "error" | "error_during_execution", - result: string, + subtype: "success" | "error" | "error_during_execution" | "error_max_turns", + result: string | null, meta?: { costUsd?: number; durationMs?: number; @@ -362,10 +367,17 @@ export function emitResult( apiErrorStatus?: number; terminalReason?: string; errors?: string[]; + // Native decouples is_error from subtype: e.g. an invalid model yields + // subtype "success" with is_error true. Default keeps the subtype-derived + // value for callers that don't care. + isError?: boolean; }, ): void { const msg: Record = { - type: "result", subtype, result, is_error: subtype !== "success", + type: "result", subtype, + // Native omits the `result` field entirely for error_max_turns. + ...(result !== null ? { result } : {}), + is_error: meta?.isError ?? subtype !== "success", session_id: sessionId, ...(meta?.costUsd != null ? { cost_usd: meta.costUsd } : {}), ...(meta?.durationMs != null ? { duration_ms: meta.durationMs } : {}), @@ -376,7 +388,7 @@ export function emitResult( ...(meta?.errors ? { errors: meta.errors } : {}), }; if (format === "text") { - process.stdout.write(result.endsWith("\n") ? result : result + "\n"); + if (result) process.stdout.write(result.endsWith("\n") ? result : result + "\n"); } else { writeLine(msg); } diff --git a/src/session.test.ts b/src/session.test.ts index 498e1a0..9ae0dd1 100644 --- a/src/session.test.ts +++ b/src/session.test.ts @@ -614,9 +614,9 @@ describe("interrupt transaction", () => { await Promise.resolve(); const response = parsedLines().find(l => l.type === "control_response"); + // Native nests request_id inside `response` (control-protocol-misc golden). expect(response).toMatchObject({ - request_id: "int-123", - response: { subtype: "success" }, + response: { subtype: "success", request_id: "int-123" }, }); }); @@ -638,8 +638,7 @@ describe("interrupt transaction", () => { expect(ptyHandle.writes).not.toContain("\x1b"); expect(parsedLines().find(l => l.type === "control_response")).toMatchObject({ - request_id: "int-dispatch", - response: { subtype: "success" }, + response: { subtype: "success", request_id: "int-dispatch" }, }); fireStatus("busy"); @@ -1119,8 +1118,10 @@ describe("handleObservation", () => { }); const result = parsedLines().find(l => l.type === "result"); + // Native parity (invalid-model golden): subtype "success" with + // is_error: true, not subtype "error". expect(result).toMatchObject({ - subtype: "error", + subtype: "success", is_error: true, api_error_status: 404, stop_reason: "api_error", @@ -1153,7 +1154,8 @@ describe("handleObservation", () => { const results = parsedLines().filter(l => l.type === "result"); expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ - subtype: "error", + subtype: "success", + is_error: true, result: "There's an issue with the selected model.", }); expect(ptyHandle.kills).toContain("SIGTERM"); @@ -1183,7 +1185,8 @@ describe("handleObservation", () => { const result = parsedLines().find(l => l.type === "result"); expect(result).toMatchObject({ - subtype: "error", + subtype: "success", + is_error: true, api_error_status: 529, result: "API Error: 529 Overloaded.", }); @@ -1718,3 +1721,133 @@ describe("prompt dispatch deadline", () => { expect(sends(ptyHandle)).toBe(1); }); }); + +// ---- Tier-2 parity: max-turns, control acks ---- + +describe("native parity shapes", () => { + beforeEach(() => { + written = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk: any) => { + written.push(typeof chunk === "string" ? chunk : chunk.toString()); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + output.configureOutput({ format: "stream-json", verbose: true, includePartial: true }); + output.resetOutputState(); + }); + afterEach(() => { vi.restoreAllMocks(); }); + + it("ends the session with result.error_max_turns and exit 1 when --max-turns is exhausted", async () => { + const { controller, fireStatus, exitCodes } = createTestController({ + args: { inputFormat: "stream-json", maxTurns: 1 }, + }); + await controller.start(); + + // Turn 1 completes normally. + fireStatus("busy"); + fireStatus("idle"); + // Turn 2 exceeds the limit: clarp interrupts it, then must report + // error_max_turns (native: is_error, NO result field, exit code 1). + fireStatus("busy"); + await Promise.resolve(); + await Promise.resolve(); + fireStatus("idle"); + await Promise.resolve(); + await Promise.resolve(); + + const results = parsedLines().filter(l => l.type === "result"); + const maxTurns = results.find(r => r.subtype === "error_max_turns"); + expect(maxTurns).toBeDefined(); + expect(maxTurns.is_error).toBe(true); + expect("result" in maxTurns).toBe(false); + + controller.handleClaudeExit(143); + await Promise.resolve(); + expect(exitCodes).toEqual([1]); + }); + + it("acks initialize with the native nested control_response shape", () => { + const { controller } = createTestController({ args: { inputFormat: "stream-json" } }); + controller.handleControlRequest({ subtype: "initialize" }, "init-1"); + + const ack = parsedLines().find(l => l.type === "control_response"); + expect(ack).toBeDefined(); + expect(ack.response.subtype).toBe("success"); + expect(ack.response.request_id).toBe("init-1"); + expect(Array.isArray(ack.response.response.commands)).toBe(true); + expect(typeof ack.response.response.pid).toBe("number"); + }); + + it("acks set_permission_mode echoing the mode like native", () => { + const { controller } = createTestController({ args: { inputFormat: "stream-json" } }); + controller.handleControlRequest({ subtype: "set_permission_mode", mode: "acceptEdits" }, "perm-1"); + + const ack = parsedLines().find(l => l.type === "control_response"); + expect(ack).toMatchObject({ + response: { subtype: "success", request_id: "perm-1", response: { mode: "acceptEdits" } }, + }); + }); + + it("counts assistant API rounds (not prompts) toward --max-turns like native", async () => { + // Native's --max-turns unit is assistant API rounds inside the agentic + // loop: one prompt that chains tool calls can exhaust it (golden: + // 1 user prompt, num_turns=2). clarp observes rounds as SSE message_start. + const { controller, fireStatus, backend, exitCodes } = createTestController({ + args: { inputFormat: "stream-json", maxTurns: 1 }, + }); + await controller.start(); + + fireStatus("busy"); + const round = (id: string) => backend.emit({ + kind: "sse", + event: { data: "{}", parsed: { type: "message_start", message: { id, model: "m", content: [] } } }, + }); + round("msg_1"); + await Promise.resolve(); + expect(parsedLines().filter(l => l.type === "result")).toHaveLength(0); + + // Second round inside the same prompt-turn exceeds the limit. + round("msg_2"); + await Promise.resolve(); + await Promise.resolve(); + fireStatus("idle"); + await Promise.resolve(); + await Promise.resolve(); + + const results = parsedLines().filter(l => l.type === "result"); + expect(results).toHaveLength(1); + expect(results[0].subtype).toBe("error_max_turns"); + expect(results[0].is_error).toBe(true); + expect(results[0].num_turns).toBe(2); + expect("result" in results[0]).toBe(false); + + controller.handleClaudeExit(143); + await Promise.resolve(); + expect(exitCodes).toEqual([1]); + }); + + it("exits 1 after an is_error result even when the session ends by stdin drain", async () => { + const { controller, fireStatus, exitCodes } = createTestController({ + args: { inputFormat: "stream-json" }, + }); + await controller.start(); + fireStatus("busy"); + // Turn fails with a backend API error (invalid-model family). + controller.handleObservation({ + kind: "transcript_line", + line: { + type: "assistant", + isApiErrorMessage: true, + apiErrorStatus: 404, + message: { content: [{ type: "text", text: "model problem" }] }, + }, + }); + controller.handleStdinEof(); + await Promise.resolve(); + await Promise.resolve(); + + controller.handleClaudeExit(143); + await Promise.resolve(); + expect(exitCodes).toEqual([1]); + }); +}); diff --git a/src/session.ts b/src/session.ts index 1f64d6f..cdf5d35 100644 --- a/src/session.ts +++ b/src/session.ts @@ -135,6 +135,13 @@ export class SessionController { private dispatchDeadlineTimer: ReturnType | null = null; private dispatchRetried = false; private turnInterrupted = false; + private maxTurnsExceeded = false; + // Assistant API rounds observed over SSE (message_start events) — native's + // --max-turns / num_turns unit. + private apiRoundCount = 0; + // Whether the most recent terminal result carried is_error — native exits 1 + // after an error result even when the session ends by stdin drain. + private lastResultIsError = false; private prefixNextPromptWithInterruptedMarker = false; private interruptedOnlyExitCode: number | null = null; private readonly startedAt = Date.now(); @@ -189,6 +196,12 @@ export class SessionController { handleObservation(obs: Observation): void { if (this.processExited) return; if (obs.kind === "sse") { + // Native's --max-turns counts assistant API rounds inside the agentic + // loop (one per message_start), not user prompts — a single prompt that + // chains tools can exhaust it. + if ((obs.event.parsed as Record | undefined)?.type === "message_start") { + this.handleAssistantRoundStarted(); + } output.emitSSE(obs.event); } else if (obs.kind === "transcript_line") { this.handleTranscriptLine(obs.line); @@ -247,13 +260,10 @@ export class SessionController { this.emitInitFromTranscriptOrFallback(); } + // Fallback for backends without SSE observation: prompt-turns can only + // undercount API rounds, so this can't fire early. if (this.opts.args.maxTurns && this.turnCount > this.opts.args.maxTurns) { - this.log(`Max turns (${this.opts.args.maxTurns}) reached`); - this.opQueue.enqueue({ - type: "interrupt", - reason: "max_turns", - emitControlResponse: false, - }); + this.triggerMaxTurnsExceeded(); } this.sendPendingDispatchInterrupt("pid_busy"); @@ -324,6 +334,27 @@ export class SessionController { requestId, emitControlResponse: true, }); + } else if (req.subtype === "initialize") { + // Native answers with a capabilities payload (commands, models, account, + // ...). clarp acks with what it can truthfully report from the observed + // session — SDK clients block on the ack itself to finish their handshake. + this.log("Initialize handshake"); + const init = this.pidWatcher.readTranscriptInit(); + const slashCommands = Array.isArray(init?.slash_commands) + ? (init.slash_commands as unknown[]).filter((c): c is string => typeof c === "string") + : []; + output.emitControlResponseSuccess(requestId, { + commands: slashCommands.map((name) => ({ name })), + output_style: typeof init?.output_style === "string" ? init.output_style : "default", + pid: this.opts.pid, + }); + } else if (req.subtype === "set_permission_mode") { + // Acked for protocol parity so clients don't wedge on the handshake. + // clarp cannot reach into the TUI to change the mode; permission + // decisions still flow through the can_use_tool protocol / auto-deny. + const mode = typeof req.mode === "string" ? req.mode : "default"; + this.log(`Set permission mode: ${mode}`); + output.emitControlResponseSuccess(requestId, { mode }); } } @@ -381,6 +412,28 @@ export class SessionController { this.requestCleanup(this.shuttingDown ? this.shutdownExitCode : 0); } + private handleAssistantRoundStarted(): void { + this.apiRoundCount++; + if ( + this.opts.args.maxTurns && + this.apiRoundCount > this.opts.args.maxTurns && + !this.maxTurnsExceeded + ) { + this.triggerMaxTurnsExceeded(); + } + } + + private triggerMaxTurnsExceeded(): void { + if (this.maxTurnsExceeded) return; + this.log(`Max turns (${this.opts.args.maxTurns}) exceeded`); + this.maxTurnsExceeded = true; + this.opQueue.enqueue({ + type: "interrupt", + reason: "max_turns", + emitControlResponse: false, + }); + } + /** * Marks stdin closed and exits when no prompt or turn is pending. */ @@ -917,6 +970,13 @@ export class SessionController { this.completeTurnWithError(this.transcriptTurnError.message, this.transcriptTurnError.status, durationMs); return; } + + if (this.maxTurnsExceeded) { + // The turn finished before the max-turns interrupt landed; it must still + // report error_max_turns, not success. + this.completeMaxTurnsExceededTurn(durationMs); + return; + } this.interruptedOnlyExitCode = null; if (!this.opts.backend.capabilities.emitsPostTurnSummary) { @@ -937,6 +997,7 @@ export class SessionController { } } + this.lastResultIsError = false; if (!this.opts.backend.capabilities.emitsResults) { output.emitResult("success", text, { durationMs, numTurns: this.turnCount }); } @@ -952,9 +1013,37 @@ export class SessionController { this.maybeShutdownAfterInputDrained(); } + /** + * Native parity: --max-turns exhaustion ends the session with a + * result.error_max_turns (is_error, no `result` field, num_turns = assistant + * API rounds) and exit code 1, even in stream-json mode — it does not keep + * serving prompts. + */ + private completeMaxTurnsExceededTurn(durationMs: number): void { + this.turnInterrupted = false; + this.transcriptTurnError = null; + this.lastResultIsError = true; + if (!this.opts.backend.capabilities.emitsResults) { + output.emitResult("error_max_turns", null, { + durationMs, + numTurns: this.apiRoundCount || this.turnCount, + stopReason: null, + }); + } + output.resetAccumulatedText(); + this.log("Max turns exceeded, exiting"); + this.requestShutdown(1); + } + private completeInterruptedTurn(durationMs: number): void { this.turnInterrupted = false; this.transcriptTurnError = null; + + if (this.maxTurnsExceeded) { + this.completeMaxTurnsExceededTurn(durationMs); + return; + } + output.emitInterruptedUserMessage(); if (!this.opts.backend.capabilities.emitsResults) { @@ -1052,6 +1141,7 @@ export class SessionController { private completeTurnWithError(message: string, status?: number, durationMs?: number): void { this.transcriptTurnError = null; + this.lastResultIsError = true; if (!this.opts.backend.capabilities.emitsPostTurnSummary) { output.emitPostTurnSummary({ @@ -1066,11 +1156,15 @@ export class SessionController { } if (!this.opts.backend.capabilities.emitsResults) { - output.emitResult("error", message, { + // Native parity (invalid-model golden): a turn that ends on a backend + // API error is reported as subtype "success" with is_error: true and the + // explanatory text as the result — not subtype "error". + output.emitResult("success", message, { durationMs, numTurns: this.turnCount, stopReason: "api_error", apiErrorStatus: status, + isError: true, }); } output.resetAccumulatedText(); @@ -1094,7 +1188,9 @@ export class SessionController { this.opQueue.length > 0 ) return; this.log("stdin closed and queue empty, exiting"); - this.requestShutdown(this.interruptedOnlyExitCode ?? 0); + // Native exits non-zero when the session's last result was an error + // (e.g. invalid model), even though the stream ended normally. + this.requestShutdown(this.interruptedOnlyExitCode ?? (this.lastResultIsError ? 1 : 0)); } private emitInitFromTranscriptOrFallback(): void { From 6a20c08316808af447eb7ee5ad00511115048d47 Mon Sep 17 00:00:00 2001 From: dn00 Date: Wed, 10 Jun 2026 14:27:04 -0700 Subject: [PATCH 2/2] fix: nest context usage control ack --- src/session.test.ts | 4 ++-- src/session.ts | 7 +------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/session.test.ts b/src/session.test.ts index 9ae0dd1..6fb571b 100644 --- a/src/session.test.ts +++ b/src/session.test.ts @@ -545,8 +545,8 @@ describe("handleControlRequest", () => { const resp = parsedLines().find(l => l.type === "control_response"); expect(resp).toBeDefined(); - expect(resp.request_id).toBe("req-123"); - expect(resp.response.context_usage).toBeDefined(); + expect(resp.response.request_id).toBe("req-123"); + expect(resp.response.response.context_usage).toBeDefined(); }); it("ignores after process exit", () => { diff --git a/src/session.ts b/src/session.ts index cdf5d35..4570dc0 100644 --- a/src/session.ts +++ b/src/session.ts @@ -314,12 +314,7 @@ export class SessionController { } else if (req.subtype === "get_context_usage" && requestId) { const usage = output.getContextUsage(); this.log(`Context usage: ${usage.input_tokens} in, ${usage.output_tokens} out`); - process.stdout.write(JSON.stringify({ - type: "control_response", - request_id: requestId, - response: { context_usage: usage }, - session_id: output.getSessionId(), - }) + "\n"); + output.emitControlResponseSuccess(requestId, { context_usage: usage }); } else if (req.subtype === "set_model") { const model = req.model as string; if (model) {