[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
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
- Install any plugin that registers a
Stophook. Theclaude-code-warpplugin (Warp terminal notifications) is one example — itshooks/hooks.jsonregistersStop → ${CLAUDE_PLUGIN_ROOT}/scripts/on-stop.sh. Any plugin whose Stop hook produces stdout will trigger it. - 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" */ }
- The Stop hook fires after the assistant turn. The CLI writes a
hook_successattachment line to the session JSONL. - Resume the same session:
``ts``
const iter2 = query({ prompt: "next prompt", options: { resume: sessionId, /* ... */ } });
for await (const evt of iter2) { /* observe events */ }
- Observe an
isMeta: trueuser message in the stream/JSONL with content"Continue from where you left off.", written before the real prompt is processed — even though step 2 endedstop_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_
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗