Skip to content

Fix: decision cards never appear in the Claude desktop app (AskUserQuestion parked by permissionDecision 'defer') - #2474

Open
habassa5 wants to merge 1 commit into
garrytan:mainfrom
habassa5:fix/askuserquestion-defer-parks-decision-cards
Open

Fix: decision cards never appear in the Claude desktop app (AskUserQuestion parked by permissionDecision 'defer')#2474
habassa5 wants to merge 1 commit into
garrytan:mainfrom
habassa5:fix/askuserquestion-defer-parks-decision-cards

Conversation

@habassa5

@habassa5 habassa5 commented Aug 7, 2026

Copy link
Copy Markdown

Summary

Fixes #2466 — AskUserQuestion decision cards never appear when gstack runs in the Claude desktop app. The question is silently swallowed, the user sees nothing, and the agent gets back [Tool result missing due to internal error].

hosts/claude/hooks/question-preference-hook.ts emitted permissionDecision: 'defer' on its no-enforcement path. In Claude Code, defer does not mean "no opinion" — it means park this tool call so a later resume can pick it up.

From the Claude Code 2.1.221 binary:

case "defer": {
  if (!n.options.isNonInteractiveSession) {
    C(`Hook ${ce.hookName} returned permissionDecision=defer in interactive mode; ignoring (defer is print-mode only)`, {level:"warn"});
    break;                                   // ignored → card renders normally
  }
  ...
  return T.push({ message: al({ type: "hook_deferred_tool", ... }) })   // parked, never runs
}

defer is honored only when the session is flagged isNonInteractiveSession — and the Claude desktop app is flagged non-interactive even with a human present. So the AskUserQuestion call is parked, never executes, and never returns.

This is why it went unreported: in the terminal CLI defer is ignored and cards work fine, which is how most people run gstack. It reproduces only in the desktop app.

The fix

Omit permissionDecision on the no-enforcement path. It is .optional() in the PreToolUse hookSpecificOutput schema:

E.object({
  hookEventName: E.literal("PreToolUse"),
  permissionDecision: DM_().optional(),
  permissionDecisionReason: E.string().optional(),
  updatedInput: E.record(E.string(), E.unknown()).optional(),
  additionalContext: E.string().optional()
})

Omitting it is also what this path already means. The hook's own docstring states:

never-ask + one-way → defer (safety override; one-way always asks)

If defer parked the tool, a one-way door would never reach the human — the exact inverse of the documented safety override. So the intent is "no enforcement, proceed normally, let the user be asked."

Only allow / deny / ask are safe to emit when the tool is expected to actually run.

What is preserved

  • additionalContext still flows → plan-tune memory-nugget injection unaffected
  • deny paths untouched → never-ask auto-decide and the Conductor prose redirect both still work (deny is a valid, honored value)
  • PostToolUse logging hooks unaffected

Verification

Bisected on a real desktop-app session with all three AUQ hooks registered:

Hooks live Result
none card renders ✅
question-preference-hook only (emits defer) [Tool result missing due to internal error]
all three, with this patch card renders ✅

Across that machine's full transcript history before the fix: 3,700+ tool calls of every other kind resolved 100%; AskUserQuestion went 0-for-8. After the fix, decision cards render and return normally, including multi-question and multiSelect cards.

Tests

14 assertions across three suites encoded the old contract and are updated to toBeUndefined(). Added a regression guard (never emits permissionDecision:defer) covering the no-preference, no-marker, empty-stdin, and non-AUQ paths, plus the one-way-door safety override.

93/93 passing across the five hook suites against current main (v1.60.2.0):

bun test test/question-preference-hook.test.ts test/memory-cache-injection.test.ts \
         test/auq-error-fallback-hook.test.ts test/question-log-hook.test.ts \
         test/hook-scripts.test.ts
# 93 pass, 0 fail

🤖 Generated with Claude Code

The PreToolUse hook for AskUserQuestion emitted `permissionDecision: 'defer'`
on its no-enforcement path. In Claude Code, 'defer' does not mean "no opinion"
— it means "park this tool call so a later resume can pick it up". It is
ignored in interactive sessions but HONORED whenever the session is flagged
non-interactive, which includes the Claude desktop app even with a human
present.

The result: every decision card was parked, never ran, and never returned. The
user saw nothing and the model received "[Tool result missing due to internal
error]". This is why it went unnoticed — in the terminal CLI 'defer' is
ignored and cards work fine.

The contract of this path is "no enforcement — proceed normally, let the user
be asked" (see the never-ask + one-way safety override, which requires the
question to still reach the human; parking it would invert that guarantee).
Claude Code encodes that by omitting permissionDecision entirely — it is
.optional() in the PreToolUse hookSpecificOutput schema.

additionalContext is still emitted, so plan-tune memory injection is
unaffected, and the deny paths (never-ask auto-decide, Conductor prose
redirect) are untouched.

Updates 14 assertions that encoded the old contract and adds a regression
guard asserting the hook never emits 'defer'.

Fixes garrytan#2466

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@trunk-io

trunk-io Bot commented Aug 7, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decision cards never appear in the Claude desktop app (PreToolUse hook sends permissionDecision: 'defer')

1 participant