anthropics / anthropics/claude-agent-sdk-typescript
interrupt() during the thinking phase resolves the aborted turn as an is_error "error_during_execution" result ([ede_diagnostic] result_type=user) instead of a clean cancellation
- Dominant language
- Shell
- Stars
- 1.8k
- Forks
- 226
- PR merge metrics
- No merged PRs in 30d
Description
## Environment [EMPIRICAL]
- **SDK:** `@anthropic-ai/claude-agent-sdk@0.3.201` (latest public, `npm i` 2026-07-06; declares `claudeCodeVersion: 2.1.201`).
- **CLI spawned:** **2.1.201** (the SDK's bundled CLI, confirmed via `system/init.claude_code_version`); also reproduced with a pinned system CLI **2.1.198** (`pathToClaudeCodeExecutable`).
- **Node:** v22.22.2 · **OS:** Linux x64 · **Model:** `claude-opus-4-8` (default).
- **Options:** streaming input (`prompt: AsyncIterable`), `includePartialMessages: true`, `permissionMode: "bypassPermissions"`, `thinking: { type: "enabled", budgetTokens: 10000 }` (forces a real thinking phase so the interrupt lands before any text). **No adapter, no framework.**
## Summary
Calling `query.interrupt()` **during the assistant thinking phase (before any assistant text is emitted)** resolves the aborted turn as an **`is_error` `error_during_execution`** result carrying:
```
[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null
```
instead of a clean cancellation. The CLI also injects a `role=user` `[Request interrupted by user]` marker, so the transcript is left on a **user→user adjacency** (two consecutive user-role records, no assistant turn between).
**The query itself recovers** — the *next* well-formed user prompt is answered normally (`stop_reason: end_turn`). So this is not a persistent failure at the SDK layer; the issue is the **error-result shape** of the interrupted turn: a mid-thinking interrupt is reported as an *error* (`is_error: true`) rather than a cancellation, which callers must special-case.
Reproduces with a **bare SDK, no adapter/framework**, on the **latest** published SDK/CLI. No history precondition — reproduces on both **virgin** and **armed** sessions.
## Reproduction (PRIMARY — attached standalone script)
The full standalone script is inlined below (uses only `@anthropic-ai/claude-agent-sdk`; headless; throwaway `/tmp` cwd; touches nothing else).
```bash
mkdir -p /tmp/sdk-wedge-repro && cd /tmp/sdk-wedge-repro
npm init -y >/dev/null && npm i @anthropic-ai/claude-agent-sdk
# copy repro-interrupt-wedge.mjs here, then:
node repro-interrupt-wedge.mjs # canonical
REPRO_ARM=0 node repro-interrupt-wedge.mjs # virgin session (still reproduces)
REPRO_CONTINUES=3 node repro-interrupt-wedge.mjs # shows the query recovers on follow-ups
REPRO_CLI=$(command -v claude) node repro-interrupt-wedge.mjs # pin a system CLI
```
What it does: (1) optionally arm one turn; (2) send a thinking-heavy prompt; (3) on the first thinking event — before any text — call `query.interrupt()`; (4) send a new well-formed prompt and observe the results; (5) dump the transcript tail.
**Result matrix [EMPIRICAL — 6/6 runs]:** the interrupted-turn `error_during_execution` + `[ede_diagnostic] result_type=user` fires in every run — armed, virgin, with the raced follow-up push, with `replay-user-messages`, on bundled CLI 2.1.201 and on system CLI 2.1.198. In every run the follow-up prompt(s) succeeded (the query recovered).
## Evidence [EMPIRICAL]
Interrupted-turn result (identical across all runs):
```
subtype=error_during_execution is_error=true stop_reason=null
errors=["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null"]
```
Transcript tail (canonical run) — the user→user adjacency forms, then the SDK answers the next prompt cleanly:
```
assistant | end_turn | "ready" <- armed turn sealed
user | - | "Before answering, think ..." <- slow prompt (interrupted, never answered)
user | - | "[Request interrupted by user]" <- CLI-injected marker, role=user
user | - | "Never mind the puzzle. ... what is 2+2?" <- next prompt
assistant | end_turn | "4" <- SDK answered despite the 3-user run
```
Records 2–4 are three consecutive `user`-role records with no assistant turn between them (the marker is CLI-authored, `role=user`); the following assistant `end_turn` shows the SDK tolerated the invalid-looking alternation and recovered.
## Expected vs Actual
- **Expected:** a mid-thinking `interrupt()` resolves the aborted turn as a **clean cancellation** (not an error).
- **Actual:** it resolves as an **`is_error` `error_during_execution`** result with the `[ede_diagnostic] result_type=user` diagnostic. (The query then recovers on the next well-formed prompt, so the residual problem is the error-result *shape*, not recoverability.)
## Where the diagnostic and marker come from [EMPIRICAL]
The strings `ede_diagnostic` / `result_type` / `last_content_type` / `error_during_execution` do **not** appear in the SDK's own JavaScript (`sdk.mjs`); the SDK spawns the `claude` CLI as a child process and streams its messages. So the `[ede_diagnostic]` result and the `[Request interrupted by user]` marker are authored **inside the Claude Code CLI**, surfaced through the SDK. (The bare repro exercises exactly this path — `query()` + `query.interrupt()`, nothing above the SDK.)
## Consumer-layer note (out of scope for this report)
[ANALYSIS] A caller that keeps one long-lived query and re-dispatches or re-throws the interrupted prompt (rather than issuing a fresh next turn) can convert this `is_error` result into a persistent stuck state. That behavior is **consumer-side** — it does **not** occur with the bare SDK (which recovers on the next turn) — and is out of scope here.
## Suggested fix (from the outside)
On a mid-thinking `interrupt()`, resolve the aborted turn as a **clean cancellation** rather than an `is_error` `error_during_execution` result. The query already recovers on the next well-formed turn, so the ask is specifically the **result shape**: the `is_error` result is what callers must special-case and what a persistent-query consumer can trip over. (Optionally, avoid leaving the transcript on a user→user adjacency by not emitting the interrupt marker as a `role=user` record when the aborted turn produced no assistant content — but the error-result shape is the primary issue.)
## Version coverage [EMPIRICAL]
Present on the **latest** published SDK **0.3.201** / CLI **2.1.201** (2026-07-06) and on system CLI **2.1.198** — 6/6 runs. Not a fixed-in-newer situation.
## Standalone reproduction script
repro-interrupt-wedge.mjs (374 lines, click to expand)
```javascript
#!/usr/bin/env node
// repro-interrupt-wedge.mjs
//
// Minimal, self-contained reproduction of an interrupt-time error result in
// @anthropic-ai/claude-agent-sdk.
//
// What it demonstrates (EMPIRICAL, 6/6 runs — SDK 0.3.201 / CLI 2.1.201, latest
// public as of 2026-07-06; also confirmed on system CLI 2.1.198):
// Calling query.interrupt() DURING the assistant's thinking phase (before any
// assistant text is emitted) resolves the aborted turn as an `is_error`
// `error_during_execution` result carrying
// [ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null
// instead of a clean cancellation. The CLI also injects a `role=user`
// `[Request interrupted by user]` marker, leaving the transcript on a
// user->user adjacency (no assistant turn between).
//
// IMPORTANT — this is NOT a permanent poison at the SDK layer:
// * the bare SDK RECOVERS on the next well-formed user prompt (answered
// cleanly with stop_reason: end_turn); and
// * it reproduces on a VIRGIN session too — a prior sealed turn is NOT
// required.
// The env knobs below (REPRO_ARM / REPRO_CONTINUES / REPRO_RACE / REPRO_REPLAY /
// REPRO_CLI) exist to test those variables; the default arms one turn only to
// mirror how the behavior was first observed.
//
// Uses ONLY the public SDK. No external framework. Runs headless in a throwaway
// cwd. Creates a deliberately-broken throwaway session.
import { query } from "@anthropic-ai/claude-agent-sdk";
import { homedir } from "node:os";
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
const t0 = Date.now();
const ts = () => `+${String(Date.now() - t0).padStart(6, " ")}ms`;
const log = (...a) => console.log(ts(), ...a);
// ---------------------------------------------------------------------------
// A pushable async-iterable input queue (streaming input mode).
// Lets us send user messages one at a time and interleave interrupt() calls.
// ---------------------------------------------------------------------------
function makeInputQueue() {
const buffer = [];
let waiting = null; // resolver for a pending next()
let ended = false;
return {
push(text) {
const msg = {
type: "user",
message: { role: "user", content: text },
parent_tool_use_id: null,
};
if (waiting) {
const r = waiting;
waiting = null;
r({ value: msg, done: false });
} else {
buffer.push(msg);
}
},
end() {
ended = true;
if (waiting) {
const r = waiting;
waiting = null;
r({ value: undefined, done: true });
}
},
async *[Symbol.asyncIterator]() {
while (true) {
if (buffer.length) {
yield buffer.shift();
continue;
}
if (ended) return;
const next = await new Promise((res) => (waiting = res));
if (next.done) return;
yield next.value;
}
},
};
}
const ARM_PROMPT = "Reply with exactly one word: ready";
const SLOW_PROMPT =
"Before answering, think very carefully, step by step, for a long time about " +
"this puzzle. A farmer must cross a river with a wolf, a goat, and a cabbage. " +
"The boat holds the farmer plus one item. The wolf eats the goat if left alone " +
"together; the goat eats the cabbage if left alone together. Enumerate every " +
"possible crossing sequence exhaustively and prove which are valid. Think " +
"extensively before writing anything.";
const CONTINUE_PROMPT = "Never mind the puzzle. Just tell me: what is 2 + 2?";
// ---- Scenario knobs (env-driven; defaults = the canonical repro) ----
const ARM = process.env.REPRO_ARM !== "0"; // arm history first (default yes)
const REPLAY = process.env.REPRO_REPLAY === "1"; // add adapter's replay-user-messages flag
const N_CONTINUE = parseInt(process.env.REPRO_CONTINUES || "1", 10); // # of post-interrupt prompts
const RACE = process.env.REPRO_RACE === "1"; // push continue immediately after interrupt(), before the error result
log(`SCENARIO: arm=${ARM} replay-user-messages=${REPLAY} continues=${N_CONTINUE} race=${RACE}`);
const input = makeInputQueue();
const q = query({
prompt: input,
options: {
permissionMode: "bypassPermissions",
includePartialMessages: true,
// Force a real, lengthy thinking phase so the interrupt lands mid-thinking,
// before any assistant text is emitted.
thinking: { type: "enabled", budgetTokens: 10000 },
maxTurns: 40,
...(REPLAY ? { extraArgs: { "replay-user-messages": null } } : {}),
// Optional: pin the spawned CLI binary (else the SDK uses its bundled CLI).
...(process.env.REPRO_CLI ? { pathToClaudeCodeExecutable: process.env.REPRO_CLI } : {}),
},
});
// ---------------------------------------------------------------------------
// State machine
// ---------------------------------------------------------------------------
let phase = "arming"; // arming -> slow -> interrupting -> continue -> done
let sealedTurns = 0;
let interruptFired = false;
let interruptTrigger = null;
let sawTextInTurn2 = false;
let sessionId = null;
let sessionCwd = null;
let claudeCodeVersion = null;
let interruptTimer = null;
const results = []; // captured result messages
async function doInterrupt(trigger) {
if (interruptFired) return;
interruptFired = true;
interruptTrigger = trigger;
if (interruptTimer) clearTimeout(interruptTimer);
log(`>>> INTERRUPT (trigger=${trigger}, sawText=${sawTextInTurn2}) — calling query.interrupt()`);
try {
await q.interrupt();
log(">>> interrupt() resolved");
} catch (e) {
log(">>> interrupt() threw:", e && e.message);
}
if (RACE && phase === "slow") {
// Race variant: send the next user prompt IMMEDIATELY after interrupt(),
// BEFORE the interrupted turn's error result is observed (a few-ms window).
phase = "continue";
continuesSent++;
log(`>>> RACE: pushing CONTINUE #${continuesSent}/${N_CONTINUE} immediately (before interrupted-turn result)`);
input.push(CONTINUE_PROMPT);
}
}
let continuesSent = 0;
let continuesSucceeded = 0;
let slowResolved = false;
// Kick off: arm the session (or, for the virgin-session control, go straight to slow).
if (ARM) {
log("PHASE arming — pushing ARM prompt");
input.push(ARM_PROMPT);
} else {
phase = "slow";
log("PHASE slow (VIRGIN — no arm) — pushing SLOW (thinking-heavy) prompt");
input.push(SLOW_PROMPT);
interruptTimer = setTimeout(() => doInterrupt("timer(6s-fallback)"), 6000);
}
// Overall watchdog so we never hang.
const watchdog = setTimeout(() => {
log("!!! watchdog fired (90s) — ending input and exiting loop");
try { input.end(); } catch {}
}, 90_000);
let loopError = null;
try {
for await (const msg of q) {
// ---- system init: capture environment ----
if (msg.type === "system" && msg.subtype === "init") {
sessionId = msg.session_id;
sessionCwd = msg.cwd;
claudeCodeVersion = msg.claude_code_version;
log(`system/init — session=${sessionId} model=${msg.model} claude_code_version=${msg.claude_code_version} cwd=${msg.cwd}`);
continue;
}
// ---- streaming partial events: detect thinking vs text in turn 2 ----
if (msg.type === "stream_event") {
const ev = msg.event;
if (phase === "slow" && !interruptFired) {
const isThinkingStart =
ev?.type === "content_block_start" && ev?.content_block?.type === "thinking";
const isThinkingDelta =
ev?.type === "content_block_delta" && ev?.delta?.type === "thinking_delta";
const isTextDelta =
ev?.type === "content_block_delta" && ev?.delta?.type === "text_delta";
const isTextStart =
ev?.type === "content_block_start" && ev?.content_block?.type === "text";
if (isThinkingStart || isThinkingDelta) {
// Mid-thinking, before any text — the target condition.
await doInterrupt(isThinkingDelta ? "thinking_delta" : "thinking_start");
} else if (isTextStart || isTextDelta) {
// Text began before we could catch thinking — still interrupt, but note it.
sawTextInTurn2 = true;
await doInterrupt("text_delta(fallback)");
}
}
continue;
}
// ---- assistant messages (committed blocks) ----
if (msg.type === "assistant") {
const kinds = (msg.message?.content || []).map((b) => b.type).join(",");
log(`assistant msg (phase=${phase}) blocks=[${kinds}]`);
continue;
}
// ---- result: a turn sealed (success) or errored ----
if (msg.type === "result") {
const errStr = msg.subtype === "success"
? `success result="${(msg.result || "").slice(0, 60).replace(/\n/g, " ")}"`
: `subtype=${msg.subtype} is_error=${msg.is_error} stop_reason=${msg.stop_reason} errors=${JSON.stringify(msg.errors)}`;
log(`result (phase=${phase}) ${errStr}`);
if (phase === "arming") {
msg.__phase = "arming";
results.push(msg);
sealedTurns++;
log(`--- ARM turn sealed (sealedTurns=${sealedTurns}). History is now ARMED. ---`);
phase = "slow";
log("PHASE slow — pushing SLOW (thinking-heavy) prompt");
input.push(SLOW_PROMPT);
// Safety net: if we never detect a thinking/text event, interrupt anyway
// after a short delay so we still land mid-turn before it seals.
interruptTimer = setTimeout(() => doInterrupt("timer(6s-fallback)"), 6000);
continue;
}
// The FIRST result after the interrupt fired is the interrupted turn's
// result — regardless of phase (RACE mode advances phase before it arrives).
if (interruptFired && !slowResolved) {
slowResolved = true;
msg.__phase = "slow";
results.push(msg);
log(`--- INTERRUPTED TURN RESOLVED --- subtype=${msg.subtype} is_error=${msg.is_error}`);
if (!RACE) {
// Non-race: now push the first continue prompt.
phase = "continue";
continuesSent++;
log(`PHASE continue — pushing CONTINUE prompt #${continuesSent}/${N_CONTINUE} to test whether the query is wedged`);
input.push(CONTINUE_PROMPT);
}
// In RACE mode the continue was already pushed inside doInterrupt().
continue;
}
msg.__phase = "continue";
results.push(msg);
{
if (msg.subtype === "success") continuesSucceeded++;
log(`--- CONTINUE #${continuesSent} RESOLVED --- subtype=${msg.subtype} is_error=${msg.is_error}`);
if (continuesSent < N_CONTINUE) {
continuesSent++;
log(`PHASE continue — pushing CONTINUE prompt #${continuesSent}/${N_CONTINUE}`);
input.push(CONTINUE_PROMPT);
} else {
phase = "done";
input.end();
}
continue;
}
}
}
} catch (e) {
loopError = e;
log("!!! OUTPUT LOOP THREW:", e && (e.stack || e.message || String(e)));
} finally {
clearTimeout(watchdog);
if (interruptTimer) clearTimeout(interruptTimer);
}
// ---------------------------------------------------------------------------
// Verdict + evidence
// ---------------------------------------------------------------------------
log("========================================================");
log("RUN COMPLETE — analysis");
log(`interrupt trigger : ${interruptTrigger}`);
log(`saw assistant text pre-interrupt: ${sawTextInTurn2}`);
log(`sealed (armed) turns before intr: ${sealedTurns}`);
log(`total result messages : ${results.length}`);
const slowResult = results.find((r) => r.__phase === "slow");
const continueResults = results.filter((r) => r.__phase === "continue");
const wedgeSignature = /ede_diagnostic|result_type=user|error_during_execution/;
function resultErrStr(r) {
if (!r) return "(none)";
if (r.subtype === "success") return `success`;
return `${r.subtype} errors=${JSON.stringify(r.errors)}`;
}
log(`interrupted-turn result : ${resultErrStr(slowResult)}`);
log(`continue prompts sent : ${continuesSent}`);
log(`continue prompts succeeded : ${continuesSucceeded}`);
continueResults.forEach((r, i) => log(` continue #${i + 1} : ${resultErrStr(r)}`));
if (loopError) log(`loop threw : ${loopError.message}`);
const slowErrored = slowResult && slowResult.subtype !== "success" && slowResult.is_error;
const anyContinueErrored =
continueResults.some((r) => r.subtype !== "success" && r.is_error) || !!loopError;
const allContinuesRecovered =
continueResults.length > 0 && continueResults.every((r) => r.subtype === "success");
const anySig =
(slowResult && JSON.stringify(slowResult.errors || "").match(wedgeSignature)) ||
continueResults.some((r) => JSON.stringify(r.errors || "").match(wedgeSignature)) ||
(loopError && wedgeSignature.test(loopError.message || ""));
log("--------------------------------------------------------");
log(`FIRST-THROW reproduced? interrupted-turn-errored=${!!slowErrored} ede-signature-seen=${!!anySig}`);
log(`PERMANENCE reproduced? any-continue-errored/threw=${!!anyContinueErrored} all-continues-recovered=${!!allContinuesRecovered}`);
log("--------------------------------------------------------");
// ---------------------------------------------------------------------------
// Transcript tail: prove consecutive user-role entries.
// ---------------------------------------------------------------------------
try {
if (sessionId) {
const projRoot = join(homedir(), ".claude", "projects");
let file = null;
if (existsSync(projRoot)) {
for (const dir of readdirSync(projRoot)) {
const cand = join(projRoot, dir, `${sessionId}.jsonl`);
if (existsSync(cand)) { file = cand; break; }
}
}
if (file) {
log(`transcript file: ${file}`);
const lines = readFileSync(file, "utf8").split("\n").filter(Boolean);
log(`transcript has ${lines.length} records — last 8 (type/role/stop_reason):`);
const tail = lines.slice(-8);
for (const l of tail) {
try {
const o = JSON.parse(l);
const role = o.message?.role || o.role;
const stop = o.message?.stop_reason ?? o.stop_reason;
let preview = "";
const c = o.message?.content;
if (typeof c === "string") preview = c.slice(0, 50);
else if (Array.isArray(c)) preview = c.map((b) => b.type === "text" ? b.text?.slice(0, 40) : `<${b.type}>`).join(" ");
log(` type=${o.type} role=${role ?? "-"} stop=${stop ?? "-"} :: ${preview.replace(/\n/g, " ")}`);
} catch {
log(" ");
}
}
// Count consecutive trailing user entries.
let trailingUsers = 0;
for (let i = lines.length - 1; i >= 0; i--) {
const o = JSON.parse(lines[i]);
const role = o.message?.role || o.role;
if (o.type === "user" || role === "user") trailingUsers++;
else break;
}
log(`consecutive trailing user-role records: ${trailingUsers}`);
} else {
log(`transcript file for session ${sessionId} not found under ${projRoot}`);
}
}
} catch (e) {
log("transcript inspection error:", e && e.message);
}
log(`SDK claude_code_version spawned: ${claudeCodeVersion}`);
log("DONE.");
process.exit(0);
```
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.