Summary
When query.interrupt() aborts an in-flight model stream, the SDK emits a truncated SDKAssistantMessage before the abort actually lands. The truncated message is structurally indistinguishable from a normal completed assistant message, so SDK consumers cannot tell that what they just received was cut off mid-stream.
The only discriminators arrive after the partial has already been emitted (synthetic "[Request interrupted by user]" user message + result.terminal_reason === "aborted_streaming"), at which point the partial has already been pushed downstream to consumers/UIs.
Reproducer
import { query } from "@anthropic-ai/claude-agent-sdk";
const ch = makeChannel(); // simple AsyncIterable wrapping a single user message
const session = query({
prompt: ch.iter,
options: {
model: "claude-opus-4-7",
systemPrompt: { type: "preset", preset: "claude_code" },
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
},
});
ch.push({
type: "user",
message: { role: "user", content: [{ type: "text", text: "Explain the entire history of the French Revolution in 6 paragraphs." }] },
parent_tool_use_id: null,
session_id: "",
uuid: crypto.randomUUID(),
});
setTimeout(() => session.interrupt(), 4000);
for await (const m of session) {
if (m.type === "assistant") {
console.log("ASSISTANT", {
stop_reason: m.message.stop_reason,
output_tokens: m.message.usage?.output_tokens,
text_len: m.message.content
.flatMap((b) => (b.type === "text" ? [b.text] : []))
.join("").length,
});
} else if (m.type === "result") {
console.log("RESULT", { subtype: m.subtype, terminal_reason: m.terminal_reason });
}
}
Observed output
+ 4004ms >>> interrupt() <<<
+ 4005ms ASSISTANT { stop_reason: null, output_tokens: 1, text_len: 164 } // ← TRUNCATED PARTIAL
+ 4006ms USER "[Request interrupted by user]"
+ 4006ms RESULT { subtype: "error_during_execution", terminal_reason: "aborted_streaming" }
The assistant message's text content ends mid-word ("The French Revolution's roots lay in the structura"). By the time the result arrives telling us this was interrupted, the consumer has already received and possibly rendered the partial.
Why existing fields don't help
I checked every field on the assistant message in both interrupted and normal-completion cases:
| field |
interrupted partial |
normal completed |
stop_reason |
null |
null (!) |
stop_sequence |
null |
null |
stop_details |
null |
null |
usage.output_tokens |
partial count |
full count |
error |
(not set — that's for auth/billing) |
(not set) |
| every other field |
same shape |
same shape |
stop_reason is null even on normal completed assistant messages, so it isn't a discriminator. The Anthropic API stops with stop_reason: "end_turn" etc., but Claude Code's wrapper appears to not pass that through to SDKAssistantMessage — it only appears on result.stop_reason.
Why we can't just wait for the next event
The synthetic user message and the result arrive after the assistant has already been yielded via the for await iterator. By then it's downstream — already delivered to whatever UI or consumer is listening. We'd need a way to either know synchronously when receiving the assistant that it was truncated, or have the SDK not emit it on interrupt.
Suggested fix
Populate stop_reason consistently — pass through the API value (end_turn, tool_use, etc.) on success and leave null only on interrupt. That alone would make stop_reason === null a discriminator.
Version
@anthropic-ai/claude-agent-sdk: 0.3.156
- Bun 1.3.13
- Model: claude-opus-4-7
Related
Summary
When
query.interrupt()aborts an in-flight model stream, the SDK emits a truncatedSDKAssistantMessagebefore the abort actually lands. The truncated message is structurally indistinguishable from a normal completed assistant message, so SDK consumers cannot tell that what they just received was cut off mid-stream.The only discriminators arrive after the partial has already been emitted (synthetic
"[Request interrupted by user]"user message +result.terminal_reason === "aborted_streaming"), at which point the partial has already been pushed downstream to consumers/UIs.Reproducer
Observed output
The assistant message's text content ends mid-word (
"The French Revolution's roots lay in the structura"). By the time theresultarrives telling us this was interrupted, the consumer has already received and possibly rendered the partial.Why existing fields don't help
I checked every field on the assistant message in both interrupted and normal-completion cases:
stop_reasonnullnull(!)stop_sequencenullnullstop_detailsnullnullusage.output_tokenserrorstop_reasonisnulleven on normal completed assistant messages, so it isn't a discriminator. The Anthropic API stops withstop_reason: "end_turn"etc., but Claude Code's wrapper appears to not pass that through toSDKAssistantMessage— it only appears onresult.stop_reason.Why we can't just wait for the next event
The synthetic
usermessage and theresultarrive after the assistant has already been yielded via thefor awaititerator. By then it's downstream — already delivered to whatever UI or consumer is listening. We'd need a way to either know synchronously when receiving the assistant that it was truncated, or have the SDK not emit it on interrupt.Suggested fix
Populate
stop_reasonconsistently — pass through the API value (end_turn,tool_use, etc.) on success and leavenullonly on interrupt. That alone would makestop_reason === nulla discriminator.Version
@anthropic-ai/claude-agent-sdk: 0.3.156Related