Skip to content

feat: make AskUserQuestion actually ask - #774

Merged
ericleepi314 merged 5 commits into
mainfrom
feat/ask-user-question-picker
Jul 31, 2026
Merged

feat: make AskUserQuestion actually ask#774
ericleepi314 merged 5 commits into
mainfrom
feat/ask-user-question-picker

Conversation

@ericleepi314

Copy link
Copy Markdown
Collaborator

Why

AskUserQuestion was registered and reachable in the interactive TUI but had never worked there. _ask_user_question_call branches on context.ask_user; the agent-server never set that hook, so every call took the dead-letter branch and returned its own questions back as {"status": "pending"} — and the default map_result_to_api JSON-dumps a dict output as the result content.

So the raw blob users saw in the transcript was the tool's return value, not a display bug, and there was no dialog to answer. The model had to fake the interaction by narrating the options in prose:

⏺ AskUserQuestion
  ⎿ {"questions": [{"header": "Scope", "question": "Which posts should I research
    and enrich?", "multiSelect": false, "options": [{"label": "The two un-sourced
    posts", "description": "Focus on 'Leadership Principles' and 'Inside DOGE' …

Now:

⏺ AskUserQuestion
  ⎿  · Which posts should I research and enrich? → The two un-sourced posts
     · How aggressively should I edit the prose? → Facts + sources only

What

A blocking control_request/control_response round trip (_AgentSession.ask_user) plus an Ink dialog: single-select, multiSelect, the auto-injected free-text row, navigation chips, back-navigation, and a review step.

Not the permission lane, even though ExitPlanMode — the other blocking interactive tool — lives there. PermissionAskReply carries only behavior/updated_input/chosen_updates and has nowhere to put structured answers; ExitPlanMode only gets away with it by smuggling its edited plan through updatedInput. This borrows the MCP-elicitation shape instead, which also keeps the tool in NO_PERMISSION_TOOLS so the questions stay the gate rather than acquiring an "Answer questions?" prompt on top of them.

Deferred (upstream has them, this doesn't): preview panes, notes/annotations, $EDITOR, image paste, plan-interview footer rows.

Defects found and fixed while building

Review found several things worth calling out, two confirmed by executing against the real component:

  • Model-controlled newlines forged option rows. stripAnsi's CONTROL_RE deliberately keeps \n, and ink's wrapText splits on it even under truncate-end, so one label rendered as several rows. The digit shortcut picks by array index, not rendered row — so "Cancel\n 2. Deploy (safe)" painted a convincing fake option 2 while pressing 2 selected the real option 2. The user reads one thing and picks another. Text is now normalized once at the component boundary (not at render, so the stored answer agrees with what was shown).
  • Duplicate question text collapsed answers. answers/picked/texts, the answered chip, allAnswered, the asked-set filter and the display envelope are all keyed by question text. Two questions sharing text marked an untouched question answered and submitted one key for both. Now rejected, mirroring TS UNIQUENESS_REFINE.
  • A client could put words in the user's mouth. Answers are now filtered to the questions actually asked, and the model-facing prose uses json.dumps per side — a " in an answer could otherwise close its pair and forge answers to questions never asked.
  • Empty answers were submitted for questions the UI called unanswered. Toggling a multiSelect option on then off serialized to '', which every surface reads as unanswered — but it was submitted anyway.
  • A force-cleared overlay stranded the backend worker for up to 30 minutes with the composer back and looking ready.
  • TextInput renders a Box, so it cannot live inside a <Text> — the free-text row crashed the moment it took focus. Typecheck and 38 unit tests were green; only the live pty run caught it.

The shared _pending machinery was refactored into one _round_trip, which fixed two pre-existing bugs in the permission lane for free: a leaked pending slot on a raising _emit, and an unlatched reply that a racing second response could overwrite (in that lane, turning a deny into an allow whose chosen_updates persist).

Deliberate divergences from upstream

Divergence Why
is_concurrency_safe = false Upstream serializes dialogs through REPL's toolUseConfirmQueue; this port has no such queue and PromptZone is an exclusive if-chain, so a second concurrent dialog would be invisible while still blocking its tool. Recorded with a _note in ts_tool_properties.json so the next parity sweep doesn't revert it.
Enter on an empty free-text row is a no-op Upstream cancels the entire dialog (select.tsx onEmptyInputSubmit), throwing away every answer already given on a stray Enter.
Trail row stays AskUserQuestion buildToolTrailLine returns header and detail as one string, so copying the TodoWrite suppression would delete the answers with it. Commented at the source.

Testing

114 new tests (33 Python + 81 TS). Beyond unit coverage, all four flows were driven live under a pty against the real TUI binary, asserting on the actual wire payloads:

Flow Wire
Esc {"action":"cancel"}
multiSelect {"How aggressively…":"Facts + sources only, Full rewrite"} (toggle order)
free text {"Which posts…":"only the DOGE one"} (raw text, not the __other__ sentinel)
toggle on/off question absent entirely, not ""
  • Python: 9095 passed, 0 failed.
  • TS: 8 failed / 1656 passed — the same 8 pre-existing failures as baseline on 62a1422a, verified by stashing this branch and re-running. Now recorded as a P0 in TODOS.md.

Two of my own tests were flaky when first written and are fixed: fixed-duration settles lose to full-suite parallel load, and press() must wait for the stream to drain, because a coalesced chunk makes ink batch two keys into one React update and the second handler then runs against the first key's pre-commit closure.

Also in here

fix(vscode): the extension dropped every control_request except can_use_tool with no reply. Harmless when permissions were the only kind; with a 30-minute AskUserQuestion timeout it wedges the session. It now declines what it cannot present. Note the server-side single-session gate does not cover this — it keys on transport, and the extension shares --stdio with the Ink TUI.

Known gaps

Recorded in TODOS.md rather than fixed here: worker housekeeping (/loop, cron, background notifications) stalls for the length of a dialog, since it only fires from _run_worker's idle branch; and Ctrl+C on any prompt overlay leaves the status line stale (pre-existing, shared with the approval branches).

🤖 Generated with Claude Code

ericleepi314 and others added 5 commits July 31, 2026 00:51
AskUserQuestion was registered and reachable in the interactive TUI but had
never worked there. `_ask_user_question_call` branches on `context.ask_user`;
the agent-server never set that hook, so every call took the dead-letter branch
and returned its own questions back as `{"status": "pending"}` -- and the
default `map_result_to_api` JSON-dumps a dict output as the result content. The
raw `{"questions": [...]}` blob users saw in the transcript was the tool's
return value, not a display bug, and no dialog existed to answer.

Add `_AgentSession.ask_user()`: a blocking control_request/control_response
round trip that returns the answers map, `None` when the user declines, or a
"proceed autonomously" map on timeout.

Deliberately NOT the permission lane, even though ExitPlanMode -- the other
blocking interactive tool -- lives there. `PermissionAskReply` carries only
behavior/updated_input/chosen_updates and has nowhere to put structured
answers; ExitPlanMode only gets away with it by smuggling its edited plan
through `updatedInput`. This borrows the MCP-elicitation shape instead: same
`_pending` round trip, free-form reply. That also keeps the tool in
NO_PERMISSION_TOOLS, so the questions stay the gate rather than acquiring an
"Answer questions?" prompt stacked on top of them.

The round trip is now shared. `permission_handler`, which can PERSIST
permission rules, had drifted from the new code in two ways that mattered:
only the new path guarded against registering after `shutdown()` took its
release snapshot, and only the new path popped its slot in a `finally`, so a
raising `_emit` leaked a slot the shutdown sweep would then try to release
forever. `_round_trip` is one implementation with both fixes; each lane keeps
its own reply interpretation, because they read genuinely different shapes and
collapsing them would be the bug. `_make_elicitation_handler` stays separate --
it is async and awaits via `run_in_executor`.

Replies are now latched. `_resolve_permission` did its lookup and its write on
either side of the lock, so a second control_response could overwrite a reply
between `event.set()` and the waiter's read; in the permission lane that turns
a user's deny into an allow whose `chosen_updates` get persisted. Lookup,
write and set now happen under one acquisition, first writer wins, and the
interrupt and shutdown sweeps honor the same latch instead of clobbering an
answer that already landed.

Trust boundaries, both directions:

- Answers are filtered to the questions actually asked. The reply is
  client-shaped and its values become prose the model reads as the user
  speaking, so an unfiltered map let a compromised client attribute statements
  to the user about anything at all.
- The model-facing prose uses `json.dumps` per side rather than bare f-string
  quotes. An answer containing `"` could otherwise close its own pair and open
  new ones, forging answers to questions never asked.
- Question texts and option labels must be unique. answers/picked/texts, the
  answered chip, allAnswered, the asked-set filter and the display envelope are
  ALL keyed by question text, and option identity is keyed by label -- so
  duplicates silently made two questions share one answer and marked an
  untouched question answered. Mirrors TS UNIQUENESS_REFINE.

`ask_user` is wired only on the single-session transport. The multi-session
--http path has no capability negotiation, so a client that ignores the
subtype would park that session's sole worker thread for the full timeout;
it gets the non-interactive substitute instead.

Two deliberate divergences from TS, both commented at the source:

- `is_concurrency_safe` is false. Upstream serializes dialogs through REPL's
  toolUseConfirmQueue; this port has no such queue and the TUI's PromptZone is
  an exclusive if-chain, so a second concurrent dialog would be invisible while
  still blocking its tool. Recorded with a `_note` in ts_tool_properties.json
  so the next parity sweep doesn't silently revert it.
- The timeout answer is its own string. The plan said to reuse headless's
  constant; "no user exists" and "a dialog was shown and ignored" are different
  facts, so they share only the instruction tail.

The outbox fallback -- still live for subagents, the SDK and MCP -- now maps to
NON_INTERACTIVE_ANSWER. It was reporting "User submitted no answers", which
fabricates an interaction that never happened.

Suite: 9095 passed / 0 failed.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The backend can now ask; this is the half that renders the question and
collects the answer.

`ask_user_question` control requests fork before the generic approval box and
publish `question.request`; `question.respond` replies with `{action:'submit',
answers}` or `{action:'cancel'}`. The pending slot is separate from
`pendingApproval`, which is single-flight -- sharing it would let a permission
ask and a question dialog clobber each other. Replying with no live slot now
returns `ok: false` rather than a false success, because the round trip can
already be over (server-side timeout, or a second reply racing the first) and
reporting ok made the app stamp "questions answered" on a turn whose answers
went nowhere.

`questionPrompt.tsx` is the dialog: single-select and multiSelect, the
auto-injected free-text row, navigation chips, back-navigation, and a review
step. Two divergences from upstream, both commented at the source. Enter on an
empty free-text row is a no-op rather than cancelling the whole dialog
(select.tsx onEmptyInputSubmit), because throwing away every answer already
given on a stray Enter is a footgun. And the trail row stays `AskUserQuestion`
rather than upstream's `User answered <product>'s questions:` header, because
`buildToolTrailLine` returns the header and its detail as one string -- copying
the TodoWrite suppression would delete the answers with it, and there is no
"result becomes the header" facility to put them back. Answers render as
`· question → answer` rows beneath, which is the outcome that mattered: a
readable summary instead of a JSON blob.

Model-controlled text is normalized once at the component boundary, not at
render. `stripAnsi` alone was not enough: its CONTROL_RE deliberately keeps
`\n`, and ink's wrapText splits on it even under `truncate-end`, so a single
option label rendered as several rows. Because the digit shortcut picks by
array index rather than by rendered row, a label like
`"Cancel\n  2. Deploy (safe)"` painted a convincing fake option 2 while
pressing 2 selected the real option 2 -- the user reads one thing and picks
another. Normalizing at entry (rather than at render) also keeps the stored
answer, the identity comparisons and the rendered row agreeing on one string;
sanitizing only at render left the raw label in the answers map that travels
back to the model.

The option list is windowed. The schema advertises 2-4 options but
schema_validation.py implements no length keywords, so the bound is a nudge to
the model, not a guarantee; past roughly a viewport the renderer drops from a
cell diff to a full-frame repaint on every keystroke and the focused row can
scroll out of sight. Same `windowItems` every other overlay here uses.

A dialog still up when the turn ends is now declined before the overlay is
dropped. `idle()` is the single place flow overlays are cleared, and the
backend worker is blocked in `ask_user`'s wait at that moment -- so
`recordError` or `/clear` would strand it for the full timeout while the
composer came back looking ready.

The approval box deliberately outranks the question dialog in PromptZone's
exclusive chain: a permission request is a gate on an action and must fail
closed, while a question is advisory. Ranked the other way, an approval
arriving during a dialog rendered nothing at all, and ApprovalPrompt mounts
with the allow option preselected.

Eight hand-maintained registries had to learn about the new overlay, two of
which are neither type-checked nor previously anchored (`usePet.isAwaitingInput`
and the tab-title marker in useMainApp, both of which silently reported the
wrong state).

Tests: 91 across five files. The mount harness drives the real component
through a PassThrough stdin, following permissionsPicker.test.tsx. Two traps
it documents: `press()` must wait for the stream to DRAIN rather than for a
fixed settle, because a coalesced chunk makes ink batch two keys into one
React update and the second handler then runs against the first key's
pre-commit closure; and `output()` is a cumulative accumulator over incremental
cell diffs, so `toContain` means "ever written" unless you clear it first.

Suite: 8 failed / 1656 passed -- the same 8 pre-existing failures as baseline
on 62a1422, +91 from this branch.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The extension matched `can_use_tool` and dropped every other inbound
control_request with no reply. That was harmless while permissions were the
only ones, but the server blocks a worker thread per control_request until it
is answered or times out -- so an unhandled subtype wedges the session with no
visible cause.

AskUserQuestion makes it reachable and expensive: its timeout is 30 minutes,
and the extension runs over `--stdio`, which `agent_server_cli.py` marks
`single_session=True` for every client. The server-side gate that keeps the
dialog off non-interactive transports therefore does NOT cover this one -- it
keys on transport, and the extension shares a transport with the Ink TUI that
does handle the subtype.

Reply `cancel`/`deny` to anything the pane cannot present, so the tool takes
its own not-answered path immediately instead of parking a thread. The real
fix is capability negotiation at init; this closes the hang in the meantime.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Three entries, none of them introduced by that branch:

- The 8 pre-existing vitest failures, as P0. They span five unrelated
  subsystems and fail identically on main with the branch stashed. A standing
  red baseline is how a known-8 quietly becomes a known-12.
- Worker housekeeping stalls for the length of a question dialog, as P2. The
  scheduler tick and background-agent notifications only fire from
  `_run_worker`'s idle branch, so a 30-minute dialog silences /loop and cron
  for 30 minutes. The blocking wait itself is correct and does not block the
  stdio pump; the stall is specifically the idle-branch housekeeping, and this
  change widened an existing 300s window rather than creating one.
- Ctrl+C on a prompt overlay leaves the status line claiming the agent is
  still waiting, as P3. The approval and plan-approval branches have the
  identical omission; the new question branch copies them.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
One conflict, in gatewayClient.ts's slash dispatch: main added a `/fusion`
case exactly where an eslint --fix blank line had landed on this branch. Kept
main's case, dropped the blank.

Everything else auto-merged. Both sides verified intact afterwards: this
branch's ask_user round trip and question dialog, and main's fusion models,
provider picker and OpenRouter catalogue refresh.

Suite on the merge result: python 9406 passed / 0 failed; TS 8 failed / 1678
passed -- the same 8 pre-existing failures as baseline, stable across 3 runs.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@ericleepi314
ericleepi314 merged commit de1cd75 into main Jul 31, 2026
2 checks passed
@ericleepi314
ericleepi314 deleted the feat/ask-user-question-picker branch July 31, 2026 22:06
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.

1 participant