Skip to content

Partial assistant message leaks through on interrupt — no way to tell it was truncated (stop_reason=null in both interrupted and normal cases) #338

@8lank

Description

@8lank

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions