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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 42 additions & 15 deletions goldens/PARITY-GAPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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 —
Expand Down
21 changes: 20 additions & 1 deletion src/message-assembler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
});
Expand Down Expand Up @@ -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" });
});
});
12 changes: 10 additions & 2 deletions src/message-assembler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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))
Expand Down
30 changes: 21 additions & 9 deletions src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): 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 } : {}),
},
});
}

Expand Down Expand Up @@ -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;
Expand All @@ -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<string, unknown> = {
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 } : {}),
Expand All @@ -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);
}
Expand Down
Loading
Loading