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 && ( +
- 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'}