mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-03-17 02:43:09 +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)}"})
|
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__":
|
if __name__ == "__main__":
|
||||||
mcp.run()
|
mcp.run()
|
||||||
|
|||||||
@@ -207,12 +207,14 @@ async def assistant_chat_websocket(websocket: WebSocket, project_name: str):
|
|||||||
Client -> Server:
|
Client -> Server:
|
||||||
- {"type": "start", "conversation_id": int | null} - Start/resume session
|
- {"type": "start", "conversation_id": int | null} - Start/resume session
|
||||||
- {"type": "message", "content": "..."} - Send user message
|
- {"type": "message", "content": "..."} - Send user message
|
||||||
|
- {"type": "answer", "answers": {...}} - Answer to structured questions
|
||||||
- {"type": "ping"} - Keep-alive ping
|
- {"type": "ping"} - Keep-alive ping
|
||||||
|
|
||||||
Server -> Client:
|
Server -> Client:
|
||||||
- {"type": "conversation_created", "conversation_id": int} - New conversation created
|
- {"type": "conversation_created", "conversation_id": int} - New conversation created
|
||||||
- {"type": "text", "content": "..."} - Text chunk from Claude
|
- {"type": "text", "content": "..."} - Text chunk from Claude
|
||||||
- {"type": "tool_call", "tool": "...", "input": {...}} - Tool being called
|
- {"type": "tool_call", "tool": "...", "input": {...}} - Tool being called
|
||||||
|
- {"type": "question", "questions": [...]} - Structured questions for user
|
||||||
- {"type": "response_done"} - Response complete
|
- {"type": "response_done"} - Response complete
|
||||||
- {"type": "error", "content": "..."} - Error message
|
- {"type": "error", "content": "..."} - Error message
|
||||||
- {"type": "pong"} - Keep-alive pong
|
- {"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):
|
async for chunk in session.send_message(user_content):
|
||||||
await websocket.send_json(chunk)
|
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:
|
else:
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
"type": "error",
|
"type": "error",
|
||||||
|
|||||||
@@ -47,8 +47,13 @@ FEATURE_MANAGEMENT_TOOLS = [
|
|||||||
"mcp__features__feature_skip",
|
"mcp__features__feature_skip",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Interactive tools
|
||||||
|
INTERACTIVE_TOOLS = [
|
||||||
|
"mcp__features__ask_user",
|
||||||
|
]
|
||||||
|
|
||||||
# Combined list for assistant
|
# 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)
|
# Read-only built-in tools (no Write, Edit, Bash)
|
||||||
READONLY_BUILTIN_TOOLS = [
|
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_create_bulk**: Create multiple features at once
|
||||||
- **feature_skip**: Move a feature to the end of the queue
|
- **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
|
## Creating Features
|
||||||
|
|
||||||
When a user asks to add a feature, use the `feature_create` or `feature_create_bulk` MCP tools directly:
|
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"):
|
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
||||||
tool_name = block.name
|
tool_name = block.name
|
||||||
tool_input = getattr(block, "input", {})
|
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 {
|
yield {
|
||||||
"type": "tool_call",
|
"type": "tool_call",
|
||||||
"tool": tool_name,
|
"tool": tool_name,
|
||||||
|
|||||||
2
ui/package-lock.json
generated
2
ui/package-lock.json
generated
@@ -55,7 +55,7 @@
|
|||||||
},
|
},
|
||||||
"..": {
|
"..": {
|
||||||
"name": "autoforge-ai",
|
"name": "autoforge-ai",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"autoforge": "bin/autoforge.js"
|
"autoforge": "bin/autoforge.js"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Send, Loader2, Wifi, WifiOff, Plus, History } from 'lucide-react'
|
|||||||
import { useAssistantChat } from '../hooks/useAssistantChat'
|
import { useAssistantChat } from '../hooks/useAssistantChat'
|
||||||
import { ChatMessage as ChatMessageComponent } from './ChatMessage'
|
import { ChatMessage as ChatMessageComponent } from './ChatMessage'
|
||||||
import { ConversationHistory } from './ConversationHistory'
|
import { ConversationHistory } from './ConversationHistory'
|
||||||
|
import { QuestionOptions } from './QuestionOptions'
|
||||||
import type { ChatMessage } from '../lib/types'
|
import type { ChatMessage } from '../lib/types'
|
||||||
import { isSubmitEnter } from '../lib/keyboard'
|
import { isSubmitEnter } from '../lib/keyboard'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -52,8 +53,10 @@ export function AssistantChat({
|
|||||||
isLoading,
|
isLoading,
|
||||||
connectionStatus,
|
connectionStatus,
|
||||||
conversationId: activeConversationId,
|
conversationId: activeConversationId,
|
||||||
|
currentQuestions,
|
||||||
start,
|
start,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
sendAnswer,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
} = useAssistantChat({
|
} = useAssistantChat({
|
||||||
projectName,
|
projectName,
|
||||||
@@ -268,6 +271,16 @@ export function AssistantChat({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Structured questions from assistant */}
|
||||||
|
{currentQuestions && (
|
||||||
|
<div className="border-t border-border bg-background">
|
||||||
|
<QuestionOptions
|
||||||
|
questions={currentQuestions}
|
||||||
|
onSubmit={sendAnswer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Input area */}
|
{/* Input area */}
|
||||||
<div className="border-t border-border p-4 bg-card">
|
<div className="border-t border-border p-4 bg-card">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -277,13 +290,13 @@ export function AssistantChat({
|
|||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Ask about the codebase..."
|
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]"
|
className="flex-1 resize-none min-h-[44px] max-h-[120px]"
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={!inputValue.trim() || isLoading || isLoadingConversation || connectionStatus !== 'connected'}
|
disabled={!inputValue.trim() || isLoading || isLoadingConversation || connectionStatus !== 'connected' || !!currentQuestions}
|
||||||
title="Send message"
|
title="Send message"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -294,7 +307,7 @@ export function AssistantChat({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from "react";
|
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";
|
type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
|
||||||
|
|
||||||
@@ -17,8 +17,10 @@ interface UseAssistantChatReturn {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
connectionStatus: ConnectionStatus;
|
connectionStatus: ConnectionStatus;
|
||||||
conversationId: number | null;
|
conversationId: number | null;
|
||||||
|
currentQuestions: SpecQuestion[] | null;
|
||||||
start: (conversationId?: number | null) => void;
|
start: (conversationId?: number | null) => void;
|
||||||
sendMessage: (content: string) => void;
|
sendMessage: (content: string) => void;
|
||||||
|
sendAnswer: (answers: Record<string, string | string[]>) => void;
|
||||||
disconnect: () => void;
|
disconnect: () => void;
|
||||||
clearMessages: () => void;
|
clearMessages: () => void;
|
||||||
}
|
}
|
||||||
@@ -36,6 +38,7 @@ export function useAssistantChat({
|
|||||||
const [connectionStatus, setConnectionStatus] =
|
const [connectionStatus, setConnectionStatus] =
|
||||||
useState<ConnectionStatus>("disconnected");
|
useState<ConnectionStatus>("disconnected");
|
||||||
const [conversationId, setConversationId] = useState<number | null>(null);
|
const [conversationId, setConversationId] = useState<number | null>(null);
|
||||||
|
const [currentQuestions, setCurrentQuestions] = useState<SpecQuestion[] | null>(null);
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const currentAssistantMessageRef = useRef<string | null>(null);
|
const currentAssistantMessageRef = useRef<string | null>(null);
|
||||||
@@ -204,6 +207,25 @@ export function useAssistantChat({
|
|||||||
break;
|
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": {
|
case "conversation_created": {
|
||||||
setConversationId(data.conversation_id);
|
setConversationId(data.conversation_id);
|
||||||
break;
|
break;
|
||||||
@@ -327,6 +349,49 @@ export function useAssistantChat({
|
|||||||
[onError],
|
[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(() => {
|
const disconnect = useCallback(() => {
|
||||||
reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnection
|
reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnection
|
||||||
if (pingIntervalRef.current) {
|
if (pingIntervalRef.current) {
|
||||||
@@ -350,8 +415,10 @@ export function useAssistantChat({
|
|||||||
isLoading,
|
isLoading,
|
||||||
connectionStatus,
|
connectionStatus,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
currentQuestions,
|
||||||
start,
|
start,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
sendAnswer,
|
||||||
disconnect,
|
disconnect,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -465,6 +465,11 @@ export interface AssistantChatConversationCreatedMessage {
|
|||||||
conversation_id: number
|
conversation_id: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AssistantChatQuestionMessage {
|
||||||
|
type: 'question'
|
||||||
|
questions: SpecQuestion[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface AssistantChatPongMessage {
|
export interface AssistantChatPongMessage {
|
||||||
type: 'pong'
|
type: 'pong'
|
||||||
}
|
}
|
||||||
@@ -472,6 +477,7 @@ export interface AssistantChatPongMessage {
|
|||||||
export type AssistantChatServerMessage =
|
export type AssistantChatServerMessage =
|
||||||
| AssistantChatTextMessage
|
| AssistantChatTextMessage
|
||||||
| AssistantChatToolCallMessage
|
| AssistantChatToolCallMessage
|
||||||
|
| AssistantChatQuestionMessage
|
||||||
| AssistantChatResponseDoneMessage
|
| AssistantChatResponseDoneMessage
|
||||||
| AssistantChatErrorMessage
|
| AssistantChatErrorMessage
|
||||||
| AssistantChatConversationCreatedMessage
|
| AssistantChatConversationCreatedMessage
|
||||||
|
|||||||
Reference in New Issue
Block a user