feat: add structured questions (AskUserQuestion) to assistant chat

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 <noreply@anthropic.com>
This commit is contained in:
Auto
2026-02-06 15:26:36 +02:00
parent 46ac373748
commit 71f17c73c2
7 changed files with 171 additions and 6 deletions

View File

@@ -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()

View File

@@ -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",

View File

@@ -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,

2
ui/package-lock.json generated
View File

@@ -55,7 +55,7 @@
},
"..": {
"name": "autoforge-ai",
"version": "0.1.6",
"version": "0.1.7",
"license": "AGPL-3.0",
"bin": {
"autoforge": "bin/autoforge.js"

View File

@@ -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({
</div>
)}
{/* Structured questions from assistant */}
{currentQuestions && (
<div className="border-t border-border bg-background">
<QuestionOptions
questions={currentQuestions}
onSubmit={sendAnswer}
/>
</div>
)}
{/* Input area */}
<div className="border-t border-border p-4 bg-card">
<div className="flex gap-2">
@@ -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}
/>
<Button
onClick={handleSend}
disabled={!inputValue.trim() || isLoading || isLoadingConversation || connectionStatus !== 'connected'}
disabled={!inputValue.trim() || isLoading || isLoadingConversation || connectionStatus !== 'connected' || !!currentQuestions}
title="Send message"
>
{isLoading ? (
@@ -294,7 +307,7 @@ export function AssistantChat({
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
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'}
</p>
</div>
</div>

View File

@@ -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<string, string | string[]>) => void;
disconnect: () => void;
clearMessages: () => void;
}
@@ -36,6 +38,7 @@ export function useAssistantChat({
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("disconnected");
const [conversationId, setConversationId] = useState<number | null>(null);
const [currentQuestions, setCurrentQuestions] = useState<SpecQuestion[] | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const currentAssistantMessageRef = useRef<string | null>(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<string, string | string[]>) => {
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,
};

View File

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