Commit 6fcca8b
authored
🤖 feat: add ask_user_question interactive plan-mode tool (#1121)
Adds a new interactive plan-mode tool `ask_user_question` (inspired by
Claude Code's `AskUserQuestion`) and a corresponding inline UI for
answering multiple-choice questions.
Key behaviors:
- Tool is only registered/available in **plan mode**.
- While the tool is pending, the UI shows an inline multi-question form.
- If the user types a normal chat message instead, the tool is canceled
(recorded as a failed tool call via `tool-error`) and the message is
sent as a real user message.
- Tool args/results are inferred from Zod schemas to avoid drift.
Validation:
- `make static-check`
---
<details>
<summary>📋 Implementation Plan</summary>
# 🤖 Plan: Add an `AskUserQuestion`-style tool to Mux (Plan Mode UX)
## Goal
Implement a tool compatible with Claude Code’s `AskUserQuestion`
semantics (per `cc-plan-mode.md`) so that, **while staying in plan
mode**, the assistant can ask the user 1–4 multiple-choice questions and
receive structured answers.
Key UX requirement: the question UI should be **non-invasive** and
**optional**—the user is encouraged to answer via the form, but they
should also be able to just type a normal chat reply without having to
explicitly “close/deny” the UI.
## Source-of-truth behavior to mirror (from `cc-plan-mode.md`)
We should match the Claude Code *behavior + schema* as closely as
practical, while using Mux’s snake_case naming convention:
- **Tool name (CC):** `AskUserQuestion`
- **Tool name (Mux):** `ask_user_question`
- **Input:** `{ questions: Question[], answers?: Record<string,string>
}`
- **Output:** `{ questions: Question[], answers: Record<string,string>
}`
- answers map: **question text → answer string**
- multi-select answers are **comma-separated**
- Constraints:
- questions: 1–4
- options per question: 2–4
- question texts unique; option labels unique within a question
- “Other” is always available (but **not** included in `options` input)
## What already exists in Mux (relevant implementation hooks)
- Tool call rendering is centralized in
`src/browser/components/Messages/ToolMessage.tsx`, which routes known
tools to custom React components; otherwise it falls back to
`GenericToolCall`.
- Tool UI primitives are in
`src/browser/components/tools/shared/ToolPrimitives.tsx` +
`toolUtils.tsx`.
- We already have reusable UI building blocks:
- `src/browser/components/ui/toggle-group.tsx` (Radix ToggleGroup;
supports `type="single"|"multiple"`)
- `src/browser/components/ui/checkbox.tsx`
- `src/browser/components/ui/input.tsx`
- `src/browser/components/ui/button.tsx`
- Tool definitions live in `src/common/utils/tools/toolDefinitions.ts`
(Zod schema + description); backend tools reference this.
- Backend tools are in `src/node/services/tools/*` and are registered
through `src/common/utils/tools/tools.ts` (`getToolsForModel`).
- Streaming tool calls are represented as `dynamic-tool` parts; while a
tool is running it’s `state: "input-available"` and UI status becomes
`"executing"`.
## Recommended approach (single approach; net new product LoC ~500–900)
### Summary
Add a new **interactive tool** `ask_user_question` (CC-compatible
`AskUserQuestion`) that:
1. **Pauses** the current assistant stream until answers are provided.
2. Renders an **inline, collapsible** multiple-choice form in the chat
(like other tool calls).
3. Lets the user **either**:
- submit answers via the form (structured), or
- type a normal chat message, which **cancels** the question tool call
and is sent as a real user message (no “deny/close” step).
### Backend design
#### 1) Tool definition (shared)
- Add `ask_user_question` to `TOOL_DEFINITIONS` with a Zod schema
matching `cc-plan-mode.md`:
- Prefer defining and exporting named sub-schemas (e.g.
`AskUserQuestionOptionSchema`, `AskUserQuestionQuestionSchema`) so the
result schema + UI types can reuse them.
- `Option`: `{ label: string; description: string }`
- `Question`: `{ question: string; header: string; options: Option[];
multiSelect: boolean }`
- Input: `{ questions: Question[]; answers?: Record<string,string> }` +
refine uniqueness
- Output: `{ questions: Question[]; answers: Record<string,string> }`
TypeScript types: **infer from Zod** (avoid handwritten interfaces) to
prevent drift.
- Args:
- `export type AskUserQuestionToolArgs = z.infer<typeof
TOOL_DEFINITIONS.ask_user_question.schema>;`
- Result:
- Define a shared `AskUserQuestionToolResultSchema` (Zod) alongside the
input schema (reusing the same `Question`/`Option` schemas), then:
- `export type AskUserQuestionToolResult = z.infer<typeof
AskUserQuestionToolResultSchema>;`
- (Optional) also export `AskUserQuestionQuestion` /
`AskUserQuestionOption` via `z.infer<...>` for the UI reducer.
#### 2) Pending-question manager (new node service)
Create an in-memory manager owned by the backend process (NOT
persisted):
- `AskUserQuestionManager`
- `registerPending(workspaceId, toolCallId, questions):
Promise<Record<string,string>>`
- `answer(workspaceId, toolCallId, answers): void`
- `cancel(workspaceId, toolCallId, reason): void`
- `getLatestPending(workspaceId): { toolCallId, questions } | null`
Important defensive behavior:
- Assert toolCallIds are unique per workspace.
- Always delete pending entries on resolve/reject.
- Add a long timeout (e.g., 30 minutes) to avoid leaks if the UI never
responds.
- Support abort: if the stream is interrupted, tool execution must
unblock (reject) so interrupt actually works.
#### 3) Tool implementation
(`src/node/services/tools/ask_user_question.ts`)
Implement `createAskUserQuestionTool(config)` using `tool()` from `ai`.
Execution flow:
- Validate args using the shared Zod schema.
- Determine `workspaceId` from `config.workspaceId` (assert present).
- Determine `toolCallId` from `ToolCallOptions.toolCallId`.
- Register the pending question with `AskUserQuestionManager` and
**await** answers.
- Return `{ questions, answers }` (matching Claude Code’s output
schema).
Abort/cancel handling:
- Race the awaited promise against an abort signal if available.
- On abort/interrupt/**cancel** (including “user responded in chat”
cancellation), **throw** an Error so the AI SDK emits a `tool-error`
part (Mux will record this as a failed tool result).
Model-context parity (optional but recommended):
- Mux providers will see tool output as structured JSON by default.
- To be more CC-like (and more token-efficient), add a small transform
in `applyToolOutputRedaction(...)` to replace the provider-facing output
for `ask_user_question` with a short string summary like:
- `User has answered your questions: "<Q>"="<A>", ... You can now
continue with the user's answers in mind.`
- while keeping the full structured `{ questions, answers }` persisted
for UI.
#### 4) ORPC endpoint to submit answers
Add a workspace-scoped ORPC endpoint:
- `workspace.answerAskUserQuestion`
- input: `{ workspaceId: string; toolCallId: string; answers:
Record<string,string> }`
- output: `Result<void, string>`
Implementation:
- Route → `WorkspaceService` → `AskUserQuestionManager.answer(...)`.
#### 5) “Chat message instead of clicking” behavior (non-invasive
requirement)
Requirement (confirmed): if the user just types a normal chat reply
while `ask_user_question` is pending, we should **not** treat it as tool
input. Instead we should:
1. **Cancel the tool call** (so it shows as failed with a message like
“User responded in chat; questions canceled”).
2. Send the user’s text as a **real user message** (normal send/queue
semantics).
Implementation sketch:
- Add `AskUserQuestionManager.getLatestPending(workspaceId)` (or
`getPendingByToolCallId`) that returns the active pending prompt.
- Modify `WorkspaceService.sendMessage()` behavior:
- if `aiService.isStreaming(workspaceId)` and a pending
`ask_user_question` exists:
- call `AskUserQuestionManager.cancel(workspaceId, toolCallId, "User
responded in chat; questions canceled")`
- then proceed with existing behavior (queue message via
`session.queueMessage(message, options)`)
- return `Ok()`
Effect:
- The stream unblocks because the tool throws → AI SDK emits
`tool-error` → Mux records a failed tool result.
- The user’s message remains a normal user message and will be processed
next (queued during streaming, then auto-sent on tool completion /
stream end, per existing queue behavior).
### Frontend design
#### 1) Add a custom tool renderer
Add `AskUserQuestionToolCall.tsx` under `src/browser/components/tools/`
and route it in `ToolMessage.tsx` similarly to `ProposePlanToolCall`.
Props needed:
- `args`, `result`, `status`, `workspaceId`
#### 2) UI behavior (inline form)
Render as an inline tool card, expanded by default while awaiting
answers:
- Header: `ask_user_question` + status
- Body:
- Tab bar for 1–4 questions (mirroring CC’s chips): show each `header`,
with a checkmark for answered.
- Per-question view:
- question text
- options:
- single-select → `ToggleGroup type="single"`
- multi-select → `Checkbox` list or `ToggleGroup type="multiple"`
- “Other”:
- option always available
- selecting “Other” reveals an `Input` for custom text
- Submit view:
- show all answers in a review list
- `Submit answers` button → calls `api.workspace.answerAskUserQuestion({
workspaceId, toolCallId, answers })`
State management:
- Use a reducer similar to CC’s (`currentQuestionIndex`, `answers`,
`questionStates`, `isInTextInput`).
Accessibility/keyboard (follow-up, but planned):
- Basic: click-based + tab focus.
- Optional parity: left/right arrow to navigate question tabs; enter to
select.
#### 3) Display after completion
When `result` is present:
- Show a compact “User answered …” summary (like CC)
- Keep details collapsible.
#### 4) Non-invasive encouragement
While the tool is pending:
- Add a small helper line in the tool body: “Tip: you can also just type
a message to respond in chat (this will cancel these questions).”
(We can later refine this into a better “waiting for input” streaming
banner, but it’s not required for v1.)
### Testing / validation (net new test LoC ~250–450)
- Unit tests for `AskUserQuestionManager`:
- registers + answers
- cancel/timeout cleanup
- rejects on abort
- Backend test for ORPC endpoint (or WorkspaceService method): answering
resolves the tool.
- Storybook:
- Add a chat story with a pending `ask_user_question` tool call
- Add a story with completed answers
## Alternatives considered
### A) Non-blocking “suggestion card” tool (net new product LoC
~300–600)
Have `ask_user_question` return immediately and not pause the stream;
user answers later and the answer is posted as a normal user message.
Downside: this diverges from Claude Code semantics and won’t behave like
the tool LLMs were trained on (tool answers won’t be available in the
same turn).
## Decisions / requirements confirmed
- If the user types a chat message while `ask_user_question` is pending:
- mark the tool call as **failed** with an output indicating the user
canceled by responding in chat
- then treat the typed text as a **real user message** (normal
queue/send behavior)
- Tool name in Mux should be `ask_user_question` (snake_case).
- No explicit “Skip” button required; a small helper label is enough.
- No feature flags: include this tool + UI by default in **plan mode**.
## Availability / gating
- Register `ask_user_question` only when `SendMessageOptions.mode ===
"plan"` (so it is available by default in plan mode, and not callable in
exec mode).
- Ensure `AIService.streamMessage()` passes `mode` into the *early*
`getToolsForModel(...)` call used for the mode-transition “Available
tools: …” sentinel. Otherwise plan mode would omit `ask_user_question`
from the advertised tool list even though it’s registered.
- UI: always render custom tool UI for `ask_user_question` tool parts
(no toggle).
---
**Net new product code estimate (recommended approach):** ~500–900 LoC
</details>
---
_Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking:
`high`_
---------
Signed-off-by: Thomas Kosiewski <tk@coder.com>1 parent 3e9740e commit 6fcca8b
File tree
23 files changed
+1306
-67
lines changed- docs
- src
- browser
- components
- Messages
- tools
- stores
- stories
- utils
- messages
- ui
- common
- orpc/schemas
- types
- utils/tools
- node
- orpc
- services
- tools
23 files changed
+1306
-67
lines changed| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
29 | 29 | | |
30 | 30 | | |
31 | 31 | | |
32 | | - | |
| 32 | + | |
33 | 33 | | |
34 | 34 | | |
35 | | - | |
| 35 | + | |
36 | 36 | | |
37 | 37 | | |
38 | | - | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
39 | 60 | | |
40 | 61 | | |
41 | 62 | | |
| |||
55 | 76 | | |
56 | 77 | | |
57 | 78 | | |
58 | | - | |
| 79 | + | |
59 | 80 | | |
60 | 81 | | |
61 | 82 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
160 | 160 | | |
161 | 161 | | |
162 | 162 | | |
163 | | - | |
| 163 | + | |
| 164 | + | |
164 | 165 | | |
165 | 166 | | |
166 | 167 | | |
| |||
673 | 674 | | |
674 | 675 | | |
675 | 676 | | |
676 | | - | |
677 | | - | |
678 | | - | |
679 | | - | |
680 | | - | |
681 | | - | |
682 | | - | |
| 677 | + | |
| 678 | + | |
| 679 | + | |
| 680 | + | |
| 681 | + | |
| 682 | + | |
| 683 | + | |
| 684 | + | |
| 685 | + | |
| 686 | + | |
| 687 | + | |
| 688 | + | |
| 689 | + | |
| 690 | + | |
683 | 691 | | |
684 | | - | |
685 | 692 | | |
686 | | - | |
687 | | - | |
688 | | - | |
| 693 | + | |
| 694 | + | |
| 695 | + | |
| 696 | + | |
| 697 | + | |
689 | 698 | | |
690 | 699 | | |
691 | | - | |
692 | | - | |
693 | | - | |
| 700 | + | |
| 701 | + | |
| 702 | + | |
| 703 | + | |
| 704 | + | |
694 | 705 | | |
695 | 706 | | |
696 | 707 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| 8 | + | |
8 | 9 | | |
9 | 10 | | |
10 | 11 | | |
| |||
29 | 30 | | |
30 | 31 | | |
31 | 32 | | |
| 33 | + | |
| 34 | + | |
32 | 35 | | |
33 | 36 | | |
34 | 37 | | |
| |||
90 | 93 | | |
91 | 94 | | |
92 | 95 | | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
93 | 101 | | |
94 | 102 | | |
95 | 103 | | |
| |||
213 | 221 | | |
214 | 222 | | |
215 | 223 | | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
216 | 238 | | |
217 | 239 | | |
218 | 240 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
99 | 99 | | |
100 | 100 | | |
101 | 101 | | |
102 | | - | |
| 102 | + | |
| 103 | + | |
103 | 104 | | |
104 | 105 | | |
105 | 106 | | |
| |||
167 | 168 | | |
168 | 169 | | |
169 | 170 | | |
170 | | - | |
| 171 | + | |
171 | 172 | | |
172 | 173 | | |
173 | 174 | | |
| |||
195 | 196 | | |
196 | 197 | | |
197 | 198 | | |
198 | | - | |
| 199 | + | |
199 | 200 | | |
200 | 201 | | |
201 | 202 | | |
| |||
213 | 214 | | |
214 | 215 | | |
215 | 216 | | |
216 | | - | |
| 217 | + | |
217 | 218 | | |
218 | 219 | | |
219 | 220 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
14 | | - | |
| 14 | + | |
15 | 15 | | |
16 | 16 | | |
17 | | - | |
| 17 | + | |
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
| |||
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
| 30 | + | |
30 | 31 | | |
31 | 32 | | |
32 | 33 | | |
33 | 34 | | |
34 | 35 | | |
35 | | - | |
| 36 | + | |
36 | 37 | | |
37 | 38 | | |
38 | 39 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
8 | | - | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
9 | 19 | | |
10 | 20 | | |
11 | 21 | | |
| |||
0 commit comments