Parked-permission "retirement" always stamps toolDenialKind:"user-rejected", even when the actual cause is an internal session reset (e.g. after /mcp reconnect), not a real user action
Summary
A tool call can be falsely reported to the model as user-rejected — tool_result text "The user doesn't want to proceed with this tool use. The tool use was rejected..." plus toolDenialKind/non_execution_kind: "user-rejected" — even when:
- the tool is already pre-approved in
settings.json'spermissions.allowlist, - no permission prompt ever appeared (confirmed directly with the user in the session where this was found — not a missed/off-screen dialog),
- the user did not press Esc, Ctrl+C, or otherwise interrupt anything.
This is distinct from #86001 (interrupting a tool call that is already executing reuses the wrong denial text) and from #51674 (closed as not_planned by the stale bot, root cause left as "unknown" — same symptom, same workaround, never actually diagnosed). Both of those start from "the user did interrupt something, but the label is wrong." This report is about a case where nothing the user did caused an interruption at all, and the false label is generated by internal retirement logic for a different reason.
Root cause (found by reading the shipped bundle directly)
Extracted the bundled application code from the actual installed claude.exe (v2.1.268, native/Bun-compiled distribution — current npm packages for this platform now ship only this compiled binary, no separate JS/source-map files) and traced the mechanism:
- There is an internal "parked permission" state machine (in the bundle's
print.ts-derived chunk) that tracks a tool call awaiting acontrol_responsedecision. - A session-reset path (a function I'll call
md()from its minified name) runs on some internal reset/session-refresh event. It aborts the currentabortControllerand, unless explicitly toldretireParkedPermission:false, calls a resume/retirement handler (Lo()) with a default reason of"interrupt"— a generic internal label, not necessarily tied to an actual keypress:
``js``
md=(p={})=>{if(on.abortController?.abort(),on.abortController=null,on.lastEmitted=null,on.pendingSuggestion=null,p.retireParkedPermission!==!1)Lo(p.parkedPermissionReason??"interrupt")}
- Inside
Lo(), when the parked entry has no persistedcontrol_responseand the reason is"timeout"(withCLAUDE_CODE_RETIRE_UNANSWERED_PARKED_PERMISSIONset) or"denied", it calls a retirement function (Uu()). Critically, the"interrupt"reason can also reachUu()through a nearby branch gated on internal flags (CLAUDE_CODE_PARKED_STOP_RETIRES/ an "adopted/hold" stateq):
``js``
if(p==="interrupt"||p==="new_input"||p==="rewind"||ze||(q||oe.kind==="adopted"?ur():F.hasUserIntentCommandsInQueue())){
...
let ce=p==="interrupt"&&a.CLAUDE_CODE_PARKED_STOP_RETIRES;
if(!ze&&!qs()&&!ao()){
if((q||ce)&&p==="interrupt")Uu(xu(oe),p,Date.now()-qr);
else if((q||Jt)&&!ui())nd()
}
Ri();return
}
Uu()is the actual retirement function. It computes a real, distinct internal outcome ("retired_unanswered"vs"retire_superseded"vs"retire_write_failed") — the information to report this accurately already exists — but for the"retired_unanswered"case it unconditionally emits, for every affectedtool_use_id, the same hardcoded denial regardless of why the entry was parked:
``js``
let Me=(async()=>{
let Ce="retired_unanswered",xe=0;
try{
let Et=[];
for(let d of p){let Ke=await DTt(d,v.storageV5);if(Ke)Et.push({toolUseId:d,assistantMessage:Ke})}
if(q())Ce="retire_superseded";
else if(Et.length>0){
let d=Et.reduce((Ke,Je)=>Je.assistantMessage.timestamp>Ke.assistantMessage.timestamp?Je:Ke).assistantMessage;
await EE([d,...Et.map(({toolUseId:Ke,assistantMessage:Je})=>Te({
content:[{type:"tool_result",content:wE,is_error:!0,tool_use_id:Ke}],
toolUseResult:wE,
toolDenialKind:"user-rejected",
sourceToolAssistantUUID:Je.uuid
})),KO({toolUse:!0})],void 0,void 0,void 0,v.storageV5);
xe=Et.length
}
}catch(Et){Ce="retire_write_failed", ...}
...
return{outcome:Ce,retiredCount:xe}
})();
where wE is the literal string:
> "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed."
So: a tool call that never got a chance to even show a permission prompt (because it was already allow-listed) can still enter this "parked" bookkeeping, then get swept up by a session-reset event using the internal "interrupt" label, then get "retired" through the exact same code path as a genuine denial — with the exact same wording — even though no prompt was ever shown and no user action occurred.
Suspected trigger in practice
In the session where this was found, the only unusual event immediately preceding both false rejections was running /mcp to reconnect an unrelated MCP server (github, for an auth token fix) earlier in the same session. This matches:
- The user's own prior, repeated observation (across multiple past sessions, not just this one) that this exact false-rejection shape recurs specifically after an
/mcpreconnect, followed by calls to a different, unrelated MCP server. - #51674's reproduction note: "
/mcpreconnect restores the tool set; after reconnect the same tool calls succeed" — i.e., reconnect is the workaround, which is consistent with reconnect-adjacent session-reset logic being the actual trigger, not a coincidence.
I was not able to fully confirm from the outside which specific internal event calls md() with the default "interrupt" reason in this scenario (that requires source not visible from the bundle alone, e.g. exact call sites into md), but the retirement path is unambiguous once a parked entry is swept up this way.
Why this matters
Same core problem as #86001, but for a strictly wider set of triggers: any code path that funnels through this "parked permission retirement" logic with reason "interrupt" produces an unconditional false "the user rejected this" signal, including in cases (like this one) where:
- the tool was already allow-listed,
- no prompt was ever shown,
- no user keypress occurred at all.
A model that trusts this text will conclude a tool genuinely failed due to explicit user intervention and may stop, backtrack, or ask the user for clarification about a rejection that never happened — actively confusing the user, who (understandably) has no idea what "interrupted" or "rejected" tool call is even being referred to.
Suggested fix direction
- In
Uu(), thread the realp/reason ("timeout","denied","interrupt", etc.) and the already-computed outcome (retired_unanswered/retire_superseded/retire_write_failed) into the emittedtool_result/toolDenialKind, instead of hardcoding"user-rejected"+ the fixedwEstring for every case. - At minimum, reserve
toolDenialKind:"user-rejected"for cases where a permission prompt genuinely was shown and genuinely was answered "no" — every other path (timeout, session reset, reconnect-driven retirement, write failure) should use a distinct, honestnon_execution_kind("interrupted"/"cancelled", or a new value if none fits) and non-accusatory wording, consistent with the intent already described in this session's own internal tool documentation string fornon_execution_kind("interrupted / cancelled for aborts ... those are NOT denials").
Related
- #86001 — mid-execution interrupt reuses the denial wording; confirmed by maintainers, but scoped to genuine in-flight Esc/interrupt, not this parked-permission-retirement path.
- #51674 — same visible symptom and workaround (
/mcpreconnect), closednot_plannedby the stale bot with root cause explicitly left "unknown." This report is the root-cause follow-up to that one. - #47282 — earlier (2026-04-13) report of the wording problem generally, also stale-closed and locked.
Environment
claude.exev2.1.268, native Windows x64 build (single compiled binary; current@anthropic-ai/claude-codenpm package is just a thin installer that fetches this binary from@anthropic-ai/claude-code-win32-x64— no separate JS bundle ships anymore for this platform/version)- Reproduced via
mcp__sublime-mcp__batchtool calls (a local stdio/SSE MCP server) failing with this exact shape twice in a row in the same session, both times with zero visible permission prompt, both times shortly after an unrelated/mcpreconnect for a different server
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗