From 71f17c73c244f12763e2125473edc66505feb165 Mon Sep 17 00:00:00 2001 From: Auto Date: Fri, 6 Feb 2026 15:26:36 +0200 Subject: [PATCH] feat: add structured questions (AskUserQuestion) to assistant chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add interactive multiple-choice question support to the project assistant, allowing it to present clickable options when clarification is needed. Backend changes: - Add ask_user MCP tool to feature_mcp.py with input validation - Add mcp__features__ask_user to assistant allowed tools list - Intercept ask_user tool calls in _query_claude() to yield question messages - Add answer WebSocket message handler in assistant_chat router - Document ask_user tool in assistant system prompt Frontend changes: - Add AssistantChatQuestionMessage type and update server message union - Add currentQuestions state and sendAnswer() to useAssistantChat hook - Handle question WebSocket messages by attaching to last assistant message - Render QuestionOptions component between messages and input area - Disable text input while structured questions are active Flow: Claude calls ask_user → backend intercepts → WebSocket question message → frontend renders QuestionOptions → user clicks options → answer sent back → Claude receives formatted answer and continues conversation. Co-Authored-By: Claude Opus 4.6 --- mcp_server/feature_mcp.py | 30 ++++++++++ server/routers/assistant_chat.py | 30 ++++++++++ server/services/assistant_chat_session.py | 21 ++++++- ui/package-lock.json | 2 +- ui/src/components/AssistantChat.tsx | 19 ++++++- ui/src/hooks/useAssistantChat.ts | 69 ++++++++++++++++++++++- ui/src/lib/types.ts | 6 ++ 7 files changed, 171 insertions(+), 6 deletions(-) diff --git a/mcp_server/feature_mcp.py b/mcp_server/feature_mcp.py index ce3859f..06535c7 100755 --- a/mcp_server/feature_mcp.py +++ b/mcp_server/feature_mcp.py @@ -984,5 +984,35 @@ def feature_set_dependencies( return json.dumps({"error": f"Failed to set dependencies: {str(e)}"}) +@mcp.tool() +def ask_user( + questions: Annotated[list[dict], Field(description="List of questions to ask, each with question, header, options (list of {label, description}), and multiSelect (bool)")] +) -> str: + """Ask the user structured questions with selectable options. + + Use this when you need clarification or want to offer choices to the user. + Each question has a short header, the question text, and 2-4 clickable options. + The user's selections will be returned as your next message. + + Args: + questions: List of questions, each with: + - question (str): The question to ask + - header (str): Short label (max 12 chars) + - options (list): Each with label (str) and description (str) + - multiSelect (bool): Allow multiple selections (default false) + + Returns: + Acknowledgment that questions were presented to the user + """ + # Validate input + for i, q in enumerate(questions): + if not all(key in q for key in ["question", "header", "options"]): + return json.dumps({"error": f"Question at index {i} missing required fields"}) + if len(q["options"]) < 2 or len(q["options"]) > 4: + return json.dumps({"error": f"Question at index {i} must have 2-4 options"}) + + return "Questions presented to the user. Their response will arrive as your next message." + + if __name__ == "__main__": mcp.run() diff --git a/server/routers/assistant_chat.py b/server/routers/assistant_chat.py index 9209128..1c3ece5 100644 --- a/server/routers/assistant_chat.py +++ b/server/routers/assistant_chat.py @@ -207,12 +207,14 @@ async def assistant_chat_websocket(websocket: WebSocket, project_name: str): Client -> Server: - {"type": "start", "conversation_id": int | null} - Start/resume session - {"type": "message", "content": "..."} - Send user message + - {"type": "answer", "answers": {...}} - Answer to structured questions - {"type": "ping"} - Keep-alive ping Server -> Client: - {"type": "conversation_created", "conversation_id": int} - New conversation created - {"type": "text", "content": "..."} - Text chunk from Claude - {"type": "tool_call", "tool": "...", "input": {...}} - Tool being called + - {"type": "question", "questions": [...]} - Structured questions for user - {"type": "response_done"} - Response complete - {"type": "error", "content": "..."} - Error message - {"type": "pong"} - Keep-alive pong @@ -303,6 +305,34 @@ async def assistant_chat_websocket(websocket: WebSocket, project_name: str): async for chunk in session.send_message(user_content): await websocket.send_json(chunk) + elif msg_type == "answer": + # User answered a structured question + if not session: + session = get_session(project_name) + if not session: + await websocket.send_json({ + "type": "error", + "content": "No active session. Send 'start' first." + }) + continue + + # Format the answers as a natural response + answers = message.get("answers", {}) + if isinstance(answers, dict): + response_parts = [] + for question_idx, answer_value in answers.items(): + if isinstance(answer_value, list): + response_parts.append(", ".join(answer_value)) + else: + response_parts.append(str(answer_value)) + user_response = "; ".join(response_parts) if response_parts else "OK" + else: + user_response = str(answers) + + # Stream Claude's response + async for chunk in session.send_message(user_response): + await websocket.send_json(chunk) + else: await websocket.send_json({ "type": "error", diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index 8c5c455..f030aa4 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -47,8 +47,13 @@ FEATURE_MANAGEMENT_TOOLS = [ "mcp__features__feature_skip", ] +# Interactive tools +INTERACTIVE_TOOLS = [ + "mcp__features__ask_user", +] + # Combined list for assistant -ASSISTANT_FEATURE_TOOLS = READONLY_FEATURE_MCP_TOOLS + FEATURE_MANAGEMENT_TOOLS +ASSISTANT_FEATURE_TOOLS = READONLY_FEATURE_MCP_TOOLS + FEATURE_MANAGEMENT_TOOLS + INTERACTIVE_TOOLS # Read-only built-in tools (no Write, Edit, Bash) READONLY_BUILTIN_TOOLS = [ @@ -123,6 +128,9 @@ If the user asks you to modify code, explain that you're a project assistant and - **feature_create_bulk**: Create multiple features at once - **feature_skip**: Move a feature to the end of the queue +**Interactive:** +- **ask_user**: Present structured multiple-choice questions to the user. Use this when you need to clarify requirements, offer design choices, or guide a decision. The user sees clickable option buttons and their selection is returned as your next message. + ## Creating Features When a user asks to add a feature, use the `feature_create` or `feature_create_bulk` MCP tools directly: @@ -402,6 +410,17 @@ class AssistantChatSession: elif block_type == "ToolUseBlock" and hasattr(block, "name"): tool_name = block.name tool_input = getattr(block, "input", {}) + + # Intercept ask_user tool calls -> yield as question message + if tool_name == "mcp__features__ask_user": + questions = tool_input.get("questions", []) + if questions: + yield { + "type": "question", + "questions": questions, + } + continue + yield { "type": "tool_call", "tool": tool_name, diff --git a/ui/package-lock.json b/ui/package-lock.json index 190768a..e54e47e 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -55,7 +55,7 @@ }, "..": { "name": "autoforge-ai", - "version": "0.1.6", + "version": "0.1.7", "license": "AGPL-3.0", "bin": { "autoforge": "bin/autoforge.js" diff --git a/ui/src/components/AssistantChat.tsx b/ui/src/components/AssistantChat.tsx index a9d8b5f..0592644 100644 --- a/ui/src/components/AssistantChat.tsx +++ b/ui/src/components/AssistantChat.tsx @@ -11,6 +11,7 @@ import { Send, Loader2, Wifi, WifiOff, Plus, History } from 'lucide-react' import { useAssistantChat } from '../hooks/useAssistantChat' import { ChatMessage as ChatMessageComponent } from './ChatMessage' import { ConversationHistory } from './ConversationHistory' +import { QuestionOptions } from './QuestionOptions' import type { ChatMessage } from '../lib/types' import { isSubmitEnter } from '../lib/keyboard' import { Button } from '@/components/ui/button' @@ -52,8 +53,10 @@ export function AssistantChat({ isLoading, connectionStatus, conversationId: activeConversationId, + currentQuestions, start, sendMessage, + sendAnswer, clearMessages, } = useAssistantChat({ projectName, @@ -268,6 +271,16 @@ export function AssistantChat({ )} + {/* Structured questions from assistant */} + {currentQuestions && ( +
+ +
+ )} + {/* Input area */}
@@ -277,13 +290,13 @@ export function AssistantChat({ onChange={(e) => setInputValue(e.target.value)} onKeyDown={handleKeyDown} placeholder="Ask about the codebase..." - disabled={isLoading || isLoadingConversation || connectionStatus !== 'connected'} + disabled={isLoading || isLoadingConversation || connectionStatus !== 'connected' || !!currentQuestions} className="flex-1 resize-none min-h-[44px] max-h-[120px]" rows={1} />

- Press Enter to send, Shift+Enter for new line + {currentQuestions ? 'Select an option above to continue' : 'Press Enter to send, Shift+Enter for new line'}

diff --git a/ui/src/hooks/useAssistantChat.ts b/ui/src/hooks/useAssistantChat.ts index b8fedff..cb660f6 100755 --- a/ui/src/hooks/useAssistantChat.ts +++ b/ui/src/hooks/useAssistantChat.ts @@ -3,7 +3,7 @@ */ import { useState, useCallback, useRef, useEffect } from "react"; -import type { ChatMessage, AssistantChatServerMessage } from "../lib/types"; +import type { ChatMessage, AssistantChatServerMessage, SpecQuestion } from "../lib/types"; type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"; @@ -17,8 +17,10 @@ interface UseAssistantChatReturn { isLoading: boolean; connectionStatus: ConnectionStatus; conversationId: number | null; + currentQuestions: SpecQuestion[] | null; start: (conversationId?: number | null) => void; sendMessage: (content: string) => void; + sendAnswer: (answers: Record) => void; disconnect: () => void; clearMessages: () => void; } @@ -36,6 +38,7 @@ export function useAssistantChat({ const [connectionStatus, setConnectionStatus] = useState("disconnected"); const [conversationId, setConversationId] = useState(null); + const [currentQuestions, setCurrentQuestions] = useState(null); const wsRef = useRef(null); const currentAssistantMessageRef = useRef(null); @@ -204,6 +207,25 @@ export function useAssistantChat({ break; } + case "question": { + // Claude is asking structured questions via ask_user tool + setCurrentQuestions(data.questions); + setIsLoading(false); + + // Attach questions to the last assistant message for display context + setMessages((prev) => { + const lastMessage = prev[prev.length - 1]; + if (lastMessage?.role === "assistant" && lastMessage.isStreaming) { + return [ + ...prev.slice(0, -1), + { ...lastMessage, isStreaming: false, questions: data.questions }, + ]; + } + return prev; + }); + break; + } + case "conversation_created": { setConversationId(data.conversation_id); break; @@ -327,6 +349,49 @@ export function useAssistantChat({ [onError], ); + const sendAnswer = useCallback( + (answers: Record) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + onError?.("Not connected"); + return; + } + + // Format answers as display text for user message + const answerParts: string[] = []; + for (const [, value] of Object.entries(answers)) { + if (Array.isArray(value)) { + answerParts.push(value.join(", ")); + } else { + answerParts.push(value); + } + } + const displayText = answerParts.join("; "); + + // Add user message to chat + setMessages((prev) => [ + ...prev, + { + id: generateId(), + role: "user", + content: displayText, + timestamp: new Date(), + }, + ]); + + setCurrentQuestions(null); + setIsLoading(true); + + // Send structured answer to server + wsRef.current.send( + JSON.stringify({ + type: "answer", + answers, + }), + ); + }, + [onError], + ); + const disconnect = useCallback(() => { reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnection if (pingIntervalRef.current) { @@ -350,8 +415,10 @@ export function useAssistantChat({ isLoading, connectionStatus, conversationId, + currentQuestions, start, sendMessage, + sendAnswer, disconnect, clearMessages, }; diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index b75d614..ba8eab9 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -465,6 +465,11 @@ export interface AssistantChatConversationCreatedMessage { conversation_id: number } +export interface AssistantChatQuestionMessage { + type: 'question' + questions: SpecQuestion[] +} + export interface AssistantChatPongMessage { type: 'pong' } @@ -472,6 +477,7 @@ export interface AssistantChatPongMessage { export type AssistantChatServerMessage = | AssistantChatTextMessage | AssistantChatToolCallMessage + | AssistantChatQuestionMessage | AssistantChatResponseDoneMessage | AssistantChatErrorMessage | AssistantChatConversationCreatedMessage