feat: make AskUserQuestion actually ask - #774
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
AskUserQuestionwas registered and reachable in the interactive TUI but had never worked there._ask_user_question_callbranches oncontext.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 defaultmap_result_to_apiJSON-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:
Now:
What
A blocking
control_request/control_responseround 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.PermissionAskReplycarries onlybehavior/updated_input/chosen_updatesand has nowhere to put structured answers;ExitPlanModeonly gets away with it by smuggling its edited plan throughupdatedInput. This borrows the MCP-elicitation shape instead, which also keeps the tool inNO_PERMISSION_TOOLSso 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:
stripAnsi'sCONTROL_REdeliberately keeps\n, and ink'swrapTextsplits on it even undertruncate-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 pressing2selected 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).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 TSUNIQUENESS_REFINE.json.dumpsper side — a"in an answer could otherwise close its pair and forge answers to questions never asked.'', which every surface reads as unanswered — but it was submitted anyway.TextInputrenders aBox, 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
_pendingmachinery 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 whosechosen_updatespersist).Deliberate divergences from upstream
is_concurrency_safe = falsetoolUseConfirmQueue; 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_noteints_tool_properties.jsonso the next parity sweep doesn't revert it.select.tsxonEmptyInputSubmit), throwing away every answer already given on a stray Enter.AskUserQuestionbuildToolTrailLinereturns header and detail as one string, so copying theTodoWritesuppression 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:
{"action":"cancel"}{"How aggressively…":"Facts + sources only, Full rewrite"}(toggle order){"Which posts…":"only the DOGE one"}(raw text, not the__other__sentinel)""62a1422a, verified by stashing this branch and re-running. Now recorded as a P0 inTODOS.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 exceptcan_use_toolwith 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--stdiowith the Ink TUI.Known gaps
Recorded in
TODOS.mdrather 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