[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

Status Open
Reported on v2.1.286
Maintainer reply None cached
Activity 0 comments · opened Oct 1, 2026

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.

View original on GitHub ↗