feat(chat): let the model ask you a question and wait (#42)#57
Conversation
The only thing the agent could put in front of the user was an approval: allow or deny a tool it was about to run. Anything else it needed to know it asked in prose, and the user had to guess the wording it wanted back. It can now ask a choice-based question — single-select or multi-select — which renders in the conversation where it was asked, is answered by clicking, and blocks the turn until it is. The mechanism was established by probe, not by documentation. Claude's own AskUserQuestion tool does not exist in the headless stream-json channel this app drives: it is absent from the init tool list, a tool search does not find it, and the model falls back to prose (.work/probes/ask/). So the capability is supplied rather than recognised, as an MCP server this app hands the runtime inline (.work/probes/askmcp/). Its one tool blocks on `tools/call` until a person clicks, which is what makes the agent genuinely wait. The answer travels back over the unix socket the approval hook already uses, generalised from one kind of caller to two. Nothing is written to the user's own settings or MCP configuration: both are passed inline, session-scoped. The question tool is auto-allowed — asking someone to approve being asked a question is two prompts for one decision — and questions are asked even when approvals are bypassed, since that setting has never meant answering for the user. The card is drawn from the tool call that asked, which is already persisted and replayed, so it survives a reload, a rejoin and a server restart with the question and the answer intact. Verified end to end against the real CLI in both approval modes: the model asks, the browser answers, and the reply names exactly what was picked.
There was a problem hiding this comment.
Pull request overview
Adds first-class support for choice-based questions (single- and multi-select) asked by the model, implemented via an MCP tool that blocks on tools/call until a user answers in the UI. This extends the existing “approval over unix socket” mechanism to also carry question prompts/answers, and renders question cards inline in the transcript with persistence across snapshot/reload.
Changes:
- Introduces an MCP server/tool (
ask_user_question) plus session wiring to let runtimes ask structured questions and block until answered (or skipped). - Adds transcript state/events for pending/answered questions, plus UI components to render/answer question cards inline (with a pinned fallback for uncorrelated questions).
- Adds extensive unit + browser checks and updates the changelog for the new capability.
Reviewed changes
Copilot reviewed 23 out of 23 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| test/chat-questions.test.js | New end-to-end/unit tests covering MCP protocol behavior, session lifecycle, reducer state, and option-id contract. |
| test/chat-permission-broker.test.js | Updates broker tests for the new { permission, question } handler interface and validates approval-only routing. |
| test/browser/checks.ts | Adds browser-level checks for inline rendering, single vs multi-select behavior, confirm flow, and persistence after answering. |
| src/shared/chat-reducer.ts | Adds pendingQuestions / answeredQuestions to transcript state and applies question / question_resolved events (incl. /clear behavior). |
| src/shared/chat-events.ts | Introduces question types/events/capability flag and shared option normalization + tool-name detection helpers. |
| src/server/websocket/messages.ts | Adds chat_question_answer message handling and routes it to the chat manager/session. |
| src/server/chat/session.ts | Wires unix-socket broker for both approvals + questions, injects MCP config/allowed tool, correlates tool calls to question cards, and implements ask/answer flows. |
| src/server/chat/permission-broker.ts | Generalizes the socket broker to route both approval asks and question asks with separate handlers and failure semantics. |
| src/server/chat/manager.ts | Passes askScript into sessions and exposes answerQuestion() to be called from websocket layer. |
| src/server/chat/ask-mcp.ts | New MCP server implementation exposing ask_user_question, bridging via the session socket, and returning a tool result the model can act on. |
| src/client/ui/relay/Icon.tsx | Adds the circle-help icon used by the question card. |
| src/client/terminal/message-handler.ts | Treats awaiting_answer as an idle tab status (similar to awaiting permission). |
| src/client/shell/chat/StreamRibbon.tsx | Adds ribbon tone/text handling for awaiting_answer. |
| src/client/shell/chat/SessionHeader.tsx | Adds header state metadata for awaiting_answer. |
| src/client/shell/chat/QuestionCard.tsx | New UI component for live + answered question cards (single-choice and multi-select with confirm/skip). |
| src/client/shell/chat/MessageList.tsx | Plumbs onAnswerQuestion callback down to message bubbles. |
| src/client/shell/chat/MessageBubble.tsx | Renders question tool blocks inline as QuestionCard (exception to “tools only on rail”), and wires transcript lookup for request/answer. |
| src/client/shell/chat/ChatView.tsx | Adds stray-question pinned rendering, wires controller answerQuestion, and treats awaiting_answer as ribbon-worthy blocking state. |
| src/client/shell/chat/ChatSessionSidebar.tsx | Updates busy-state/dot/label handling for awaiting_answer. |
| src/client/chat/turns.ts | Marks the last turn as “waiting” for both awaiting-permission and awaiting-answer states. |
| src/client/chat/transcript.ts | Hydrates and exposes pendingQuestions, plus helpers to find question/answer by tool id. |
| src/client/chat/controller.ts | Adds answerQuestion() to send chat_question_answer messages. |
| CHANGELOG.md | Documents the new “agent can ask you a question and wait” feature for 5.2.0. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| t: 'question_resolved', | ||
| requestId, | ||
| toolId: entry.request.toolId, | ||
| optionIds: [], | ||
| skipped: true, |
| for (const [, entry] of this.questions) { | ||
| entry.resolve({ labels: [], error: 'the session was stopped' }); | ||
| } | ||
| this.questions.clear(); |
| */ | ||
| export const ASK_QUESTION_TOOL_NAME = `mcp__${ASK_MCP_SERVER}__${ASK_QUESTION_TOOL}`; | ||
|
|
||
| /** |
| options: { | ||
| type: 'array', | ||
| minItems: 2, | ||
| description: 'The answers on offer. Keep this to a short, fixed list the user can scan.', | ||
| items: { |
Wires the question channel into the ACP adapter, where servers are offered in
the handshake (`session/new`) rather than on the command line. Same script,
same socket, same tool; which of the two routes a runtime takes is now a
property of its registry row, so a runtime without a verified route reports
`questions: false` instead of being handed a flag nobody has watched it parse.
Probed against omp before writing any of it, and the probe paid for itself
three times over — none of this is visible from the Claude path alone:
- omp names the very same tool `mcp__ccweb_ask_user_question`, with a *single*
underscore. The matcher required two, so every ACP question would have failed
to render a card.
- ACP has no tool-name field at all. The adapter uses the agent's own title for
the block ("Asking tabs vs spaces preference"), and the real name turns up
inside the arguments instead. Recognition now consults both, and the question
payload is read from either the arguments themselves or omp's envelope, which
carries them as a JSON string beside a path.
- omp's model got the option schema wrong on its first attempt, had the call
rejected before it ever reached this server, and retried. That leaves two
announced calls for one question, and claiming by announcement order pinned
the card to the attempt that failed. Correlation is now by question text,
falling back to order only when a runtime reports no arguments to match on.
The schema now accepts a bare string as an option as well as an object, since
that rejected first attempt was a round trip spent on a shape this server
always understood.
kimi is wired by the same row but is unverified: it returns `end_turn` with no
output for any prompt here, with or without this change.
Verified end to end through the real ChatSession: omp asks both a single- and a
multi-select question, both correlate to real tool ids, and the reply names
exactly what was picked. Claude re-verified unchanged. 1423 unit tests, 315
browser checks, 0 failures.
ACP wired (kimi, omp)Follow-up commit: the question channel now also reaches ACP agents, which take MCP servers in the handshake ( Probed against omp first, and it found three things the Claude path could never have shown:
The tool schema also now accepts a bare string as an option as well as an object: that rejected first attempt was a wasted round trip on a shape this server always understood. Verification
Honest gap: |
kimi turned out to ship a native `AskUserQuestion`, and in a headless ACP session it answers itself with "the user dismissed this" without anybody being asked. Its MCP support is fine — verified: it spawns this app's server, exposes the tool as `mcp__ccweb__ask_user_question`, and round-trips an answer when the model picks it. It just often picks its own instead. That makes two rules load-bearing that were previously only incidentally correct, so they are now covered: a runtime's own ask-the-user tool must not be auto-approved by the rule that exists for this app's tool, and must not draw a question card — there is no pending question behind it, so the card would render as already-answered with nothing in it. Refusing the native call through the ACP permission channel was tried and does not redirect the model: the turn simply ends, because ACP carries no reason back. Recorded in the registry rather than worked around, since wiring kimi anyway is still strictly better than not.
kimi: now configured, and the answer is nuancedRe-probed with kimi working. Its MCP support is fine — the earlier "unverified" note was wrong about the cause:
The problem is tool preference, not transport. kimi ships its own native Tried and rejected: refusing the native call through the ACP permission channel, hoping the model would fall back to the working tool. It does not — the turn just ends. ACP's rejection carries no reason back to the model, so there is nothing to redirect it with. Not shipped, since it changes behaviour without fixing anything. Conclusion: kimi stays wired, because doing so is strictly better than not — when the model picks this tool the question reaches a person, and when it picks its own, the outcome is the one kimi would have produced anyway. This is recorded in the registry comment rather than papered over. What this did change: two rules that were only incidentally correct are now load-bearing and covered by tests. A runtime's own ask-the-user tool must not be auto-approved by the rule that exists for this app's tool, and must not draw a question card — there is no pending question behind it, so the card would render as already-answered with nothing in it. 1424 unit tests, 315 browser checks, 0 failures. Verified status by runtime: claude ✅ · omp ✅ · kimi |
#54 landed on the release branch while this was in review. Only the changelog actually conflicted — two entries added to the same list — and both are kept. Re-tested on the merged result rather than trusting each PR's own green: 1443 unit tests and 319 browser checks pass together.
Closes #42.
Targets
chore/version-bump-5.2.0(PR #50), notmain.What changed
The agent can now ask a question with the answers already written out, instead
of asking in prose and hoping the user guesses the wording it wants back. The
question renders in the conversation where it was asked, is answered by
clicking, and blocks the turn until it is. Both kinds are supported: pick
exactly one, or tick several and confirm.
The card stays after the answer, showing what was asked and what was chosen, so
scrolling back past a decision shows the decision. A pending question rides the
session snapshot, so closing the tab and coming back leaves it there and still
answerable. Skipping is offered and reaches the model as a skip, so the turn
never hangs on a card someone walked away from.
Why it works this way
Established by probe rather than by documentation, and the traces are kept:
.work/probes/ask/— Claude's ownAskUserQuestiontool does not existin the headless
-p --output-format stream-jsonchannel this app drives. Itis absent from the
system/inittool list, a tool search does not find it,and the model gives up and asks in prose. The capability cannot be recognised;
it has to be supplied.
.work/probes/askmcp/— supplying it as an MCP tool works end to end. Themodel discovers it, calls it with a structured payload, blocks while the
server withholds its reply, and continues from the answer. Verified for both
multiSelect: falseandtruein one conversation.The blocking
tools/callis the whole feature: it is what makes the agentgenuinely wait rather than guess and apologise afterwards.
The answer travels back over the unix socket the approval hook already uses,
generalised from one kind of caller to two. Nothing is written to the user's own
settings or MCP config — both are passed inline and are session-scoped, for the
same reason the approval hook is.
Two deliberate asymmetries with the approval path:
as an error telling the model to ask in prose. Nothing is gated on the answer,
so there is no safe direction to fail towards, and silence would hang the turn.
act". It has never meant "answer my questions for me", so questions are asked
either way — and the question tool itself is auto-allowed, because asking
someone to approve being asked something is two prompts for one decision.
The card is drawn from the tool call that asked, which is already persisted and
replayed, so the record survives a reload, a rejoin and a server restart without
any side table.
Verification
npm test— 1418 passing, including 40 new tests: the MCP protocol drivenover real pipes, the spawned server against a real broker socket, the session
round trip, reducer state, and the shared option-id contract both sides mint
against.
npm run test:browser— 315 PASS, 0 FAIL, including 17 new checks that alayout engine has to be running to answer: the card renders inside the message
that asked (not in a tray), a multi-select needs its confirm and does not
answer on the first tick, and the card survives being answered.
claudeCLI, in both approval modes: the modelasks, the harness answers, and the final reply names exactly what was picked.
Three defects found and fixed by the adversarial pass
sessionevent replaces the capability record wholesale, sothe
questionsflag set at startup was dropped the moment Claude introduceditself — true on the server, false in every browser. Patched on the event.
calls in one message would have made the first question claim the second
call's id, drawing both cards in the wrong place. Now a FIFO, drained at
turn_endso a stale id cannot mispair a later turn./clearemptied the transcript but left the cards behind, hanging off toolblocks that were no longer on screen.
Scope
Runtime wiring lands for claude, which is the runtime the mechanism was
verified against. The event model, broker, MCP server and UI are all
runtime-neutral; other adapters report
questions: falseuntil their ownhandshake is wired and probed. ACP's
session/newdoes take anmcpServersarray, so that is the cheapest next one — but it is not wired blind here.
Open questions from the issue
is told about. Silent dismissal is not: a card that can be made to vanish
without answering is how a turn hangs forever.
composer stays live while a question is pending, so a user who needs to say
something else can just type it.
🤖 Generated with Claude Code