[BUG] Heuristic: skip `interrupted_turn` injection when the most recent assistant ended `stop_reason: "end_turn"` and the only trailing line is a hook artifact

Resolved 💬 1 comment Opened May 23, 2026 by sichangli Closed Jun 23, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

When a session is resumed via the SDK (query({ resume: sessionId })), the CLI's Vj3() detector treats any trailing attachment line as evidence of an interrupted turn and injects an isMeta: true user prompt with content "Continue from where you left off.". This includes harmless post-turn artifacts like hook_success lines, which any plugin that registers a Stop hook will produce after a normally-completed turn.

The contract is sound — isMeta and isVisibleInTranscriptOnly are clear signals to consumers, and apps that honor them work correctly. The narrow ask is whether Vj3() could be more precise about when interruption actually happened.

Specifically: in the JSONL evidence below, the previous assistant message has stop_reason: "end_turn" — an unambiguous "this turn finished cleanly." Roughly 600ms later, a Stop hook fires and writes a hook_success attachment. On the next resume, Vj3() sees the attachment as the chain leaf and returns { kind: "interrupted_turn" }, ignoring that the assistant immediately upstream said it was done.

Decoded Vj3() from the v2.1.149 binary via strings:

function Vj3(H) {
  if (H.length === 0) return { kind: "none" };
  let _ = H.findLastIndex(K =>
    K.type !== "system" &&
    K.type !== "progress" &&
    !(K.type === "assistant" && K.isApiErrorMessage)
  );
  let q = _ !== -1 ? H[_] : void 0;
  if (!q) return { kind: "none" };
  if (q.type === "assistant") return { kind: "none" };
  if (q.type === "user") {
    if (q.isMeta || q.isCompactSummary) return { kind: "none" };
    if (rW_(q)) {
      if (Nj3(q, H, _)) return { kind: "none" };
      return { kind: "interrupted_turn" };
    }
    return { kind: "interrupted_prompt", message: q };
  }
  if (q.type === "attachment") return { kind: "interrupted_turn" };  // ← over-fires
  return { kind: "none" };
}

The branch falls through whenever the leaf is any attachment, regardless of whether the most recent assistant turn ended cleanly.

Tangentially related: SDKAssistantMessage events from the TS SDK don't carry parentUuid (only parent_tool_use_id). Consumers that want to filter out the meta-prompt's assistant reply (typically "No response requested.") can't do it from stream events alone — they have to round-trip through the on-disk JSONL where parentUuid is populated. Surfacing parentUuid on assistant events would let consumers honor isMeta end-to-end without disk reads. (Worth flagging here for context but probably belongs in a separate SDK issue if useful.)

What Should Happen?

Two equally good options:

Option A — check the assistant's stop_reason before flagging interruption. When the leaf is an attachment but the most recent assistant message in the chain has a clean stop_reason (e.g. "end_turn", "stop_sequence"), don't fire interrupted_turn. Only fire when the assistant's stop reason actually implies in-flight work (e.g. "tool_use" with no subsequent tool_result, or absent stop_reason).

Option B — restrict the attachment branch by attachment subtype. Treat only attachment subtypes that genuinely represent in-flight tool/file work (e.g. partial uploads) as interruption-worthy. Subtypes like hook_success, hook_failure, file-history-snapshot are post-turn observability artifacts and should be skipped — same way the predicate already skips system and progress.

Option A is more general and probably more robust to future attachment subtypes. Option B is a smaller patch that handles the most common case directly.

Either way, the user-visible win is that plugins with Stop hooks (e.g. claude-code-warp) stop producing a synthetic prompt + contextless model reply on every resumed turn. Apps would still need to honor isMeta for the genuine interruption case, but the false-positive volume drops to near zero in normal use.

Error Messages/Logs

Steps to Reproduce

  1. Install any plugin that registers a Stop hook. The claude-code-warp plugin (Warp terminal notifications) is one example — its hooks/hooks.json registers Stop → ${CLAUDE_PLUGIN_ROOT}/scripts/on-stop.sh. Any plugin whose Stop hook produces stdout will trigger it.
  2. Run a normal turn via the TS SDK and let the assistant complete normally:

``ts
import { query } from "@anthropic-ai/claude-agent-sdk";
const iter = query({ prompt: "hi", options: { /* ... */ } });
for await (const evt of iter) { /* model replies "Hi!" with stop_reason: "end_turn" */ }
``

  1. The Stop hook fires after the assistant turn. The CLI writes a hook_success attachment line to the session JSONL.
  2. Resume the same session:

``ts
const iter2 = query({ prompt: "next prompt", options: { resume: sessionId, /* ... */ } });
for await (const evt of iter2) { /* observe events */ }
``

  1. Observe an isMeta: true user message in the stream/JSONL with content "Continue from where you left off.", written before the real prompt is processed — even though step 2 ended stop_reason: "end_turn" and there's no in-flight work to resume.

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.149

Platform

AWS Bedrock

Operating System

macOS

Terminal/Shell

Warp

Additional Information

_No response_

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗