[BUG] stream-json: interrupt sent right after a user message is acknowledged but ignored on every turn after the first; the turn runs in full
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report
- [x] I am using the latest version of Claude Code (2.1.286)
What's Wrong?
In stream-json input mode (the mode the Agent SDKs drive), an interrupt control request sent right after a user message is acknowledged with success, then ignored. The turn runs to completion and bills its tokens.
This happens on every turn after the first in a session. On the first turn, the same interrupt cancels the turn. An interrupt sent after the turn's system/init message is honoured on every turn.
The CLI answers the interrupt before it emits that turn's system/init. It looks like the CLI applies the interrupt to the idle session, finds no running turn, and then starts the queued turn anyway.
Measured on 2.1.286, --model haiku. Interrupt written immediately after the user message:
| | turn cancelled | turn ran in full |
|---|---|---|
| first turn of the session | 4 / 4 | 0 / 4 |
| a later turn (after one completed turn) | 0 / 7 | 7 / 7 |
| a later turn, interrupt sent 10–50 ms later (after system/init) | 2 / 2 | 0 / 2 |
On a later turn, the event order is:
2ms control_response (interrupt) success
6ms system/init
... (the full answer streams)
4838ms result subtype=success is_error=false output_tokens=671
On the first turn the order is the same up to system/init, and then [Request interrupted by user] follows, then result subtype=error_during_execution.
This hits any host that lets a user press Stop just after sending a message. The stop reports success, and the turn the user tried to cancel runs anyway. We first saw it on 2.1.274 through a Go SDK host. We now work around it by re-sending the interrupt when the turn's system/init arrives.
What Should Happen?
An interrupt that arrives after a user message has been written should cancel that turn, as it does on the first turn. Or, if the CLI has no turn to interrupt, the control_response should say so rather than success, so a host can tell the stop did nothing.
Error Messages/Logs
None; every response is success.
Steps to Reproduce
Save as repro.py and run python3 repro.py in an empty directory. It makes two small Haiku calls (about $0.01).
# Repro: an interrupt sent right after a user message, on any turn after the first, is
# acknowledged but dropped, and the turn runs to completion.
import json, subprocess, sys
cmd = ["claude", "-p", "--input-format", "stream-json", "--output-format", "stream-json",
"--verbose", "--model", "haiku", "--setting-sources", "", "--strict-mcp-config", "--tools", ""]
p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1)
def send(obj):
p.stdin.write(json.dumps(obj) + "\n")
p.stdin.flush()
def user(text):
return {"type": "user", "session_id": "", "parent_tool_use_id": None,
"message": {"role": "user", "content": text}}
def until_result():
for line in p.stdout:
m = json.loads(line)
if m["type"] in ("control_response", "result") or m.get("subtype") == "init":
print(" ", m["type"], m.get("subtype") or m.get("response", {}).get("subtype"),
m.get("usage", {}).get("output_tokens", ""))
if m["type"] == "result":
return m
send({"type": "control_request", "request_id": "init", "request": {"subtype": "initialize"}})
send(user("Reply with just: ok"))
print("turn 1 (no interrupt):")
until_result()
send(user("Write the integers from 1 to 300, separated by single spaces, and nothing else."))
send({"type": "control_request", "request_id": "stop", "request": {"subtype": "interrupt"}})
print("turn 2 (interrupt sent right after the user message):")
r = until_result()
print("turn 2 was", "STOPPED" if r["subtype"] == "error_during_execution" else "NOT STOPPED: it ran in full")
p.stdin.close()
p.wait()
Output:
turn 1 (no interrupt):
control_response success
system init
result success 53
turn 2 (interrupt sent right after the user message):
control_response success
system init
result success 672
turn 2 was NOT STOPPED: it ran in full
Remove the first turn (send the long prompt and the interrupt as turn 1), and the turn is cancelled (error_during_execution, 0 output tokens).
Claude Model
Haiku in the repro. The interrupt is handled before any model call, so the model probably doesn't matter.
Is this a regression?
I don't know. First observed on 2.1.274.
Claude Code Version
2.1.286 (Claude Code)
Platform
Anthropic API
Operating System
macOS 26.6.2 (arm64)
Terminal/Shell
Non-interactive: claude -p --input-format stream-json --output-format stream-json, driven over stdin/stdout.
---
Filed by @onsi. The measurements and repro were produced and verified by Claude (Claude Code) working under my direction; I reviewed them before filing.