Skip to content

feat(chat): let the model ask you a question and wait (#42)#57

Merged
dnviti merged 4 commits into
chore/version-bump-5.2.0from
feat/issue-42-choice-questions
Jul 26, 2026
Merged

feat(chat): let the model ask you a question and wait (#42)#57
dnviti merged 4 commits into
chore/version-bump-5.2.0from
feat/issue-42-choice-questions

Conversation

@dnviti

@dnviti dnviti commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Closes #42.

Targets chore/version-bump-5.2.0 (PR #50), not main.

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 own AskUserQuestion tool does not exist
    in the headless -p --output-format stream-json channel this app drives. It
    is absent from the system/init tool 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. The
    model discovers it, calls it with a structured payload, blocks while the
    server withholds its reply, and continues from the answer. Verified for both
    multiSelect: false and true in one conversation.

The blocking tools/call is the whole feature: it is what makes the agent
genuinely 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:

  • Failing open, not closed. A question that cannot reach anyone comes back
    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.
  • Bypass does not apply. Bypassing approvals means "stop asking me before you
    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 test1418 passing, including 40 new tests: the MCP protocol driven
    over 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:browser315 PASS, 0 FAIL, including 17 new checks that a
    layout 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.
  • End to end against the real claude CLI, in both approval modes: the model
    asks, the harness answers, and the final reply names exactly what was picked.

Three defects found and fixed by the adversarial pass

  • The runtime's own session event replaces the capability record wholesale, so
    the questions flag set at startup was dropped the moment Claude introduced
    itself — true on the server, false in every browser. Patched on the event.
  • Tool-call correlation held a single slot. A runtime announcing two question
    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_end so a stale id cannot mispair a later turn.
  • /clear emptied the transcript but left the cards behind, hanging off tool
    blocks 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: false until their own
handshake is wired and probed. ACP's session/new does take an mcpServers
array, so that is the cheapest next one — but it is not wired blind here.

Open questions from the issue

  • Dismiss/skip? — Skipping is offered, but only as an explicit answer the model
    is told about. Silent dismissal is not: a card that can be made to vanish
    without answering is how a turn hangs forever.
  • Per-option free-text "other"? — Out of scope per the issue's non-goals. The
    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

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.
Copilot AI review requested due to automatic review settings July 26, 2026 23:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +718 to +722
t: 'question_resolved',
requestId,
toolId: entry.request.toolId,
optionIds: [],
skipped: true,
Comment on lines +941 to +944
for (const [, entry] of this.questions) {
entry.resolve({ labels: [], error: 'the session was stopped' });
}
this.questions.clear();
Comment thread src/shared/chat-events.ts
*/
export const ASK_QUESTION_TOOL_NAME = `mcp__${ASK_MCP_SERVER}__${ASK_QUESTION_TOOL}`;

/**
Comment on lines +76 to +80
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.
@dnviti

dnviti commented Jul 26, 2026

Copy link
Copy Markdown
Owner Author

ACP wired (kimi, omp)

Follow-up commit: the question channel now also reaches ACP agents, which take MCP servers in the handshake (session/new) rather than on the command line. Which of the two routes a runtime uses is a property of its registry row, so anything without a verified route still reports questions: false rather than getting a flag nobody has watched it parse.

Probed against omp first, and it found three things the Claude path could never have shown:

  1. omp names the very same tool mcp__ccweb_ask_user_question — a single underscore. The matcher required two, so every ACP question would have silently failed to render a card.
  2. ACP has no tool-name field at all. The adapter uses the agent's own title for the block ("Asking tabs vs spaces preference"); the real name turns up inside the arguments instead. Recognition now consults both, and the question payload is read from either the arguments directly or omp's envelope, which carries them as a JSON string beside a path.
  3. A retry breaks order-based correlation. omp's model got the option schema wrong, the call was rejected before it ever reached this server, and it retried — leaving two announced calls for one question. 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 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

  • End to end through the real ChatSession with omp: single- and multi-select questions both asked, both correlated to real tool ids, answers delivered, and the model's closing sentence names exactly what was picked.
  • Claude re-verified unchanged after the correlation rewrite, in both approval modes.
  • 1423 unit tests, 315 browser checks, 0 failures.

Honest gap: kimi is wired by the same registry row but is unverified — it returns end_turn with no output for any prompt on this machine, with or without this change. That looks like an auth/quota problem in kimi itself rather than anything here, but I have not seen it work, so I am not claiming it does.

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.
@dnviti

dnviti commented Jul 26, 2026

Copy link
Copy Markdown
Owner Author

kimi: now configured, and the answer is nuanced

Re-probed with kimi working. Its MCP support is fine — the earlier "unverified" note was wrong about the cause:

  • kimi spawns this app's server (confirmed by process tree during a live turn),
  • exposes it as mcp__ccweb__ask_user_question — double underscore, like Claude,
  • and round-trips an answer correctly when the model calls it.

The problem is tool preference, not transport. kimi ships its own native AskUserQuestion, and in a headless ACP session that tool answers itself — it returns {"answers":{},"note":"User dismissed the question without answering."} without anybody being asked. The model then reports "you dismissed the questions" to a user who was never shown one.

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. AskUserQuestion matches neither the name rule nor the payload rule, and there is now a test saying so.

1424 unit tests, 315 browser checks, 0 failures.

Verified status by runtime: claude ✅ · omp ✅ · kimi ⚠️ (channel works, tool choice unreliable) · codex/pi/grok — questions: false, unprobed.

#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.
@dnviti
dnviti merged commit 68b1809 into chore/version-bump-5.2.0 Jul 26, 2026
4 checks passed
@dnviti dnviti self-assigned this Jul 26, 2026
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.

2 participants