mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-03-16 18:33:08 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
2
ui/package-lock.json
generated
@@ -55,7 +55,7 @@
|
||||
},
|
||||
"..": {
|
||||
"name": "autoforge-ai",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.7",
|
||||
"license": "AGPL-3.0",
|
||||
"bin": {
|
||||
"autoforge": "bin/autoforge.js"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user