Background task notifications cancel pending permission requests with a message that impersonates the user

Status Open
Reported on v2.1.224
Maintainer reply None cached
Activity 4 comments · opened Aug 10, 2026

Background task notifications cancel pending permission requests with a message that impersonates the user

Environment

  • @anthropic-ai/claude-agent-sdk 0.3.224 (bundled CLI 2.1.224), macOS (darwin arm64)
  • Streaming input mode (query({ prompt: asyncIterable })), permissionMode: 'default', canUseTool round-trip via SDK control protocol
  • Background subagents launched with the Agent tool (run_in_background)

Summary

When a background task finishes while the main conversation is mid-turn, its <task-notification> enters the CLI's internal message queue. If the model then issues a tool call that needs a permission decision (a can_use_tool round-trip), the CLI does not raise the permission request. Instead it cancels the tool call and writes this exact sentinel as an is_error tool_result:

The user doesn't want to take this action right now. STOP what you are doing and wait for the user to tell you how to proceed.

It then delivers the queued notification (the transcript shows queue-operation: remove within 20–600 ms of the sentinel).

That sentinel text is correct when a human pressed Escape in the interactive CLI. Here, no human did anything — an automated notification triggered it. The model reads it as a explicit user stop/deny, halts, apologizes for overstepping, and (when it happens inside a subagent that was about to write its results) discards completed work.

Impact observed (one real session, 2026-08-09)

  • 8 phantom cancellations in one evening session; zero real user denials or interrupts (verified against the host app's logs: no interrupt request, no deny, no watchdog).
  • Two research subagents were cancelled exactly at their final report-write; ~88k tokens of finished work discarded.
  • The main agent repeatedly told the operator "you stopped me / you declined my edit" when the operator had done nothing, and an escalation loop followed (agent tries to "rescue" the stopped subagent, hits the same collision again).

The failure rate scales with subagent count: the more background tasks, the more often a notification is queued at the moment of a permission ask, and in a 5-subagent session nearly every permission ask in the completion window was phantom-cancelled.

Repro sketch

  1. Streaming-input session with canUseTool (permission round-trips to the host), default permission mode.
  2. Ask the model to launch 3–5 background Agent tasks (several minutes each), then continue working in the main thread on actions that require permission (e.g. Write to a new path).
  3. When a background task completes while a can_use_tool request would be raised, observe the pending tool call resolve with the sentinel above instead of the host receiving a can_use_tool request, immediately followed by delivery of the queued <task-notification>.
  4. Transcript JSONL shows the correlation: queue-operation records (enqueue/remove) bracketing the sentinel tool_result within well under a second.

Expected

An automated task-notification should not be treated as user input for interruption semantics. Either:

  • deliver the notification without cancelling the pending tool call / permission request, or
  • if cancellation is unavoidable, use a sentinel that names the true cause (e.g. "Tool call cancelled: a queued system notification pre-empted it — this was not a user action"), so the model does not attribute the stop to the human.

Notes

  • The same collision presumably applies to any programmatically queued message in streaming-input mode, not just task-notifications; task-notifications are the common automated source.
  • Host-side we now detect the sentinel (no matching operator deny/interrupt on record) and steer a corrective note into the turn, but the wording fix belongs upstream: every SDK consumer with background tasks and permission prompts hits this.

View original on GitHub ↗

4 Comments

Yogitmeister · 7 days ago

Independent corroboration on Windows, plus binary-level evidence for the wording half of this report.

Environment

  • Claude Code 2.1.231, Windows 10 (claude-code-win32-x64), and the same constant in the Nimbalyst-bundled @anthropic-ai/claude-agent-sdk-win32-x64 copy
  • Background subagents launched via the Agent tool with run_in_background: true
  • No deny rules configured; permissions.defaultMode: "auto"

Existing repros here are macOS (#88203) and Linux (#78288) — adding Windows, so this is all three platforms.

What happened

Five background subagents across one session. Every one of them died, and three subsequent tool calls (two Agent launches, one internal tool-schema load) came back as:

The user doesn't want to take this action right now. STOP what you are doing and wait for the user to tell you how to proceed.

The operator did nothing. He was on his phone, and the first he knew of any of it was the agent telling him he had blocked it. The agent then wrote him two formal BLOCKED ON: <decision> messages asking him to choose how to proceed — from a decision he had never made. Two full turns produced zero deliverable while the agent re-litigated a permission that was never actually denied.

That is the same escalation loop described in the original report, and it is worth emphasizing that the damage is not only the cancelled call. The model treats the sentinel as ground truth about the user's intent and reasons downstream from it — it stops, apologizes, asks the human to unblock something the human never blocked, and discards work.

The wording asymmetry (new evidence)

The sentinel is a compile-time constant in the shipped binary. From claude.exe (2.1.231):

"[Request interrupted by user]"
"[Request interrupted by user for tool use]"
Yj = "The user doesn't want to take this action right now. STOP what you are doing and wait for the user to tell you how to proceed."

Yj sits in the same minified block as the two interrupt sentinels — i.e. it is the generic reject/interrupt fallback.

Meanwhile the other denial sources in the same binary already attribute themselves correctly:

case "hook": return t.reason
  ? `Hook '${t.hookName}' blocked this action: ${t.reason}`
  : `Hook '${t.hookName}' requires approval for this ${e} command`;
case "rule": { ... }

So hook denials name the hook and give a reason. Rule denials name the rule. Only the fallback path — the single case where the cause is genuinely unknown — asserts a specific human intent. It is the one message that cannot know what it claims, and it is the only one phrased as a first-person statement about the user.

Suggested fix for the wording half

Independent of the queue/abort race (#86650, #88203), the sentinel should not claim to know why the call stopped. Something like:

Action denied; source unattributed. May be an interrupt rather than a decision. Do not infer user intent.

That is 98 characters against the original's 118, so it is a drop-in replacement.

This is worth decoupling from the race fix, for two reasons:

  1. It is a much smaller change with independent value. Even after the race is fixed, timeouts (#78288), transport loss (#79246), and abort-controller reuse (#86650) all funnel into this same string. Every one of them currently reports as a user refusal.
  2. It converts a silent, compounding failure into a visible one. Today the model has no way to distinguish "the human declined" from "something broke," so it confidently misattributes to the human and stops. With neutral wording it would retry, route around, or report honestly.

A machine-readable source field on the error (user | hook | rule | timeout | transport | unknown) would be better still, and would let agent harnesses handle the unattributed case explicitly rather than parsing prose.

Why this matters beyond the lost tokens

The operator's reaction on being shown the root cause was, verbatim: "Can you change the wording from 'The user doesn't want to take this action'?!?! 'Action denied'? With a reason...?"

The current string does not just misinform the model. It manufactures a false record of a user decision, in the user's own voice, in their transcript — and the agent then argues back to them from it.

csaunders-ldt · 4 days ago

Seeing this on a ubuntu sandbox too.

It's pretty nasty - it poisons the entire session so any future tool calls get blocked as if the user cancelled them.

Yogitmeister · 4 days ago

Exactly!  This is incredibly bad!On 26 Aug 2026, at 12:16, csaunders-ldt @.***> wrote:csaunders-ldt left a comment (anthropics/claude-code#85408)
Seeing this on a ubuntu sandbox too.
It's pretty nasty - it poisons the entire session so any future tool calls get blocked as if the user cancelled them.

—Reply to this email directly, view it on GitHub, or unsubscribe.Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you commented.Message ID: @.***>

Brodin2001 · 3 days ago

Hey — your Claude Agent SDK issue around background tasks cancelling permission requests caught my attention.

I'm experimenting with separating the policy decision from the agent's conversational state so that approval isn't dependent on the model/runtime getting the surrounding interaction exactly right.

I'd love to see whether that architecture helps with any of these edge cases.

If you're interested, I'll help install the prototype for free against a small workflow. All I'd ask is honest feedback after you've tried it — particularly whether the separation makes things safer or just more complicated.