From 398c9d492f57306487578ffd74eb74b17ee1b9c3 Mon Sep 17 00:00:00 2001 From: Connor Tyndall Date: Fri, 9 Jan 2026 06:10:43 -0600 Subject: [PATCH] feat: Enable assistant chat to create and manage features Allow users to interact with the project assistant to create features through natural conversation. The assistant can now: - Create single features via `feature_create` tool - Create multiple features via `feature_create_bulk` - Skip features to deprioritize them via `feature_skip` Changes: - Add `feature_create` MCP tool for single-feature creation - Update assistant allowed tools to include feature management - Update system prompt to explain new capabilities - Enhance UI tool call display with friendly messages Security: File system access remains read-only. The assistant cannot modify source code or mark features as passing (requires actual implementation by the coding agent). Co-Authored-By: Claude Opus 4.5 --- mcp_server/feature_mcp.py | 52 ++++ server/services/assistant_chat_session.py | 67 ++++- ui/src/hooks/useAssistantChat.ts | 337 +++++++++++++--------- 3 files changed, 302 insertions(+), 154 deletions(-) mode change 100644 => 100755 mcp_server/feature_mcp.py mode change 100644 => 100755 server/services/assistant_chat_session.py mode change 100644 => 100755 ui/src/hooks/useAssistantChat.ts diff --git a/mcp_server/feature_mcp.py b/mcp_server/feature_mcp.py old mode 100644 new mode 100755 index 8c5f3c8..b1542fd --- a/mcp_server/feature_mcp.py +++ b/mcp_server/feature_mcp.py @@ -15,6 +15,7 @@ Tools: - feature_mark_in_progress: Mark a feature as in-progress - feature_clear_in_progress: Clear in-progress status - feature_create_bulk: Create multiple features at once +- feature_create: Create a single feature """ import json @@ -413,5 +414,56 @@ def feature_create_bulk( session.close() +@mcp.tool() +def feature_create( + category: Annotated[str, Field(description="Feature category (e.g., 'Authentication', 'API', 'UI')")], + name: Annotated[str, Field(description="Feature name")], + description: Annotated[str, Field(description="Detailed description of the feature")], + steps: Annotated[list[str], Field(description="List of implementation/verification steps")] +) -> str: + """Create a single feature in the project backlog. + + Use this when the user asks to add a new feature, capability, or test case. + The feature will be added with the next available priority number. + + Args: + category: Feature category for grouping (e.g., 'Authentication', 'API', 'UI') + name: Descriptive name for the feature + description: Detailed description of what this feature should do + steps: List of steps to implement or verify the feature + + Returns: + JSON with the created feature details including its ID + """ + session = get_session() + try: + # Get the next priority + max_priority_result = session.query(Feature.priority).order_by(Feature.priority.desc()).first() + next_priority = (max_priority_result[0] + 1) if max_priority_result else 1 + + db_feature = Feature( + priority=next_priority, + category=category, + name=name, + description=description, + steps=steps, + passes=False, + ) + session.add(db_feature) + session.commit() + session.refresh(db_feature) + + return json.dumps({ + "success": True, + "message": f"Created feature: {name}", + "feature": db_feature.to_dict() + }, indent=2) + except Exception as e: + session.rollback() + return json.dumps({"error": str(e)}) + finally: + session.close() + + if __name__ == "__main__": mcp.run() diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py old mode 100644 new mode 100755 index a9b556a..c6c6c1a --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -29,13 +29,23 @@ logger = logging.getLogger(__name__) # Root directory of the project ROOT_DIR = Path(__file__).parent.parent.parent -# Read-only feature MCP tools (no mark_passing, skip, create_bulk) +# Read-only feature MCP tools READONLY_FEATURE_MCP_TOOLS = [ "mcp__features__feature_get_stats", "mcp__features__feature_get_next", "mcp__features__feature_get_for_regression", ] +# Feature management tools (create/skip but not mark_passing) +FEATURE_MANAGEMENT_TOOLS = [ + "mcp__features__feature_create", + "mcp__features__feature_create_bulk", + "mcp__features__feature_skip", +] + +# Combined list for assistant +ASSISTANT_FEATURE_TOOLS = READONLY_FEATURE_MCP_TOOLS + FEATURE_MANAGEMENT_TOOLS + # Read-only built-in tools (no Write, Edit, Bash) READONLY_BUILTIN_TOOLS = [ "Read", @@ -60,17 +70,30 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str: except Exception as e: logger.warning(f"Failed to read app_spec.txt: {e}") - return f"""You are a helpful project assistant for the "{project_name}" project. + return f"""You are a helpful project assistant and backlog manager for the "{project_name}" project. -Your role is to help users understand the codebase, answer questions about features, and explain how code works. You have READ-ONLY access to the project files. +Your role is to help users understand the codebase, answer questions about features, and manage the project backlog. You can READ files and CREATE/MANAGE features, but you cannot modify source code. -IMPORTANT: You CANNOT modify any files. You can only: +## What You CAN Do + +**Codebase Analysis (Read-Only):** - Read and analyze source code files - Search for patterns in the codebase - Look up documentation online - Check feature progress and status -If the user asks you to make changes, politely explain that you're a read-only assistant and they should use the main coding agent for modifications. +**Feature Management:** +- Create new features/test cases in the backlog +- Skip features to deprioritize them (move to end of queue) +- View feature statistics and progress + +## What You CANNOT Do + +- Modify, create, or delete source code files +- Mark features as passing (that requires actual implementation by the coding agent) +- Run bash commands or execute code + +If the user asks you to modify code, explain that you're a project assistant and they should use the main coding agent for implementation. ## Project Specification @@ -78,14 +101,35 @@ If the user asks you to make changes, politely explain that you're a read-only a ## Available Tools -You have access to these read-only tools: +**Code Analysis:** - **Read**: Read file contents - **Glob**: Find files by pattern (e.g., "**/*.tsx") - **Grep**: Search file contents with regex - **WebFetch/WebSearch**: Look up documentation online + +**Feature Management:** - **feature_get_stats**: Get feature completion progress - **feature_get_next**: See the next pending feature -- **feature_get_for_regression**: See passing features +- **feature_get_for_regression**: See passing features for testing +- **feature_create**: Create a single feature in the backlog +- **feature_create_bulk**: Create multiple features at once +- **feature_skip**: Move a feature to the end of the queue + +## Creating Features + +When a user asks to add a feature, gather the following information: +1. **Category**: A grouping like "Authentication", "API", "UI", "Database" +2. **Name**: A concise, descriptive name +3. **Description**: What the feature should do +4. **Steps**: How to verify/implement the feature (as a list) + +You can ask clarifying questions if the user's request is vague, or make reasonable assumptions for simple requests. + +**Example interaction:** +User: "Add a feature for S3 sync" +You: I'll create that feature. Let me add it to the backlog... +[calls feature_create with appropriate parameters] +You: Done! I've added "S3 Sync Integration" to your backlog. It's now visible on the kanban board. ## Guidelines @@ -93,7 +137,8 @@ You have access to these read-only tools: 2. When explaining code, reference specific file paths and line numbers 3. Use the feature tools to answer questions about project progress 4. Search the codebase to find relevant information before answering -5. If you're unsure, say so rather than guessing""" +5. When creating features, confirm what was created +6. If you're unsure about details, ask for clarification""" class AssistantChatSession: @@ -144,14 +189,14 @@ class AssistantChatSession: self.conversation_id = conv.id yield {"type": "conversation_created", "conversation_id": self.conversation_id} - # Build permissions list for read-only access + # Build permissions list for assistant access (read + feature management) permissions_list = [ "Read(./**)", "Glob(./**)", "Grep(./**)", "WebFetch", "WebSearch", - *READONLY_FEATURE_MCP_TOOLS, + *ASSISTANT_FEATURE_TOOLS, ] # Create security settings file @@ -191,7 +236,7 @@ class AssistantChatSession: model="claude-opus-4-5-20251101", cli_path=system_cli, system_prompt=system_prompt, - allowed_tools=[*READONLY_BUILTIN_TOOLS, *READONLY_FEATURE_MCP_TOOLS], + allowed_tools=[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS], mcp_servers=mcp_servers, permission_mode="bypassPermissions", max_turns=100, diff --git a/ui/src/hooks/useAssistantChat.ts b/ui/src/hooks/useAssistantChat.ts old mode 100644 new mode 100755 index 3d40f87..00c43b4 --- a/ui/src/hooks/useAssistantChat.ts +++ b/ui/src/hooks/useAssistantChat.ts @@ -2,120 +2,129 @@ * Hook for managing assistant chat WebSocket connection */ -import { useState, useCallback, useRef, useEffect } from 'react' -import type { ChatMessage, AssistantChatServerMessage } from '../lib/types' +import { useState, useCallback, useRef, useEffect } from "react"; +import type { ChatMessage, AssistantChatServerMessage } from "../lib/types"; -type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error' +type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"; interface UseAssistantChatOptions { - projectName: string - onError?: (error: string) => void + projectName: string; + onError?: (error: string) => void; } interface UseAssistantChatReturn { - messages: ChatMessage[] - isLoading: boolean - connectionStatus: ConnectionStatus - conversationId: number | null - start: (conversationId?: number | null) => void - sendMessage: (content: string) => void - disconnect: () => void - clearMessages: () => void + messages: ChatMessage[]; + isLoading: boolean; + connectionStatus: ConnectionStatus; + conversationId: number | null; + start: (conversationId?: number | null) => void; + sendMessage: (content: string) => void; + disconnect: () => void; + clearMessages: () => void; } function generateId(): string { - return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}` + return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; } export function useAssistantChat({ projectName, onError, }: UseAssistantChatOptions): UseAssistantChatReturn { - const [messages, setMessages] = useState([]) - const [isLoading, setIsLoading] = useState(false) - const [connectionStatus, setConnectionStatus] = useState('disconnected') - const [conversationId, setConversationId] = useState(null) + const [messages, setMessages] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [connectionStatus, setConnectionStatus] = + useState("disconnected"); + const [conversationId, setConversationId] = useState(null); - const wsRef = useRef(null) - const currentAssistantMessageRef = useRef(null) - const reconnectAttempts = useRef(0) - const maxReconnectAttempts = 3 - const pingIntervalRef = useRef(null) - const reconnectTimeoutRef = useRef(null) + const wsRef = useRef(null); + const currentAssistantMessageRef = useRef(null); + const reconnectAttempts = useRef(0); + const maxReconnectAttempts = 3; + const pingIntervalRef = useRef(null); + const reconnectTimeoutRef = useRef(null); // Clean up on unmount useEffect(() => { return () => { if (pingIntervalRef.current) { - clearInterval(pingIntervalRef.current) + clearInterval(pingIntervalRef.current); } if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current) + clearTimeout(reconnectTimeoutRef.current); } if (wsRef.current) { - wsRef.current.close() + wsRef.current.close(); } - } - }, []) + }; + }, []); const connect = useCallback(() => { // Prevent multiple connection attempts - if (wsRef.current?.readyState === WebSocket.OPEN || - wsRef.current?.readyState === WebSocket.CONNECTING) { - return + if ( + wsRef.current?.readyState === WebSocket.OPEN || + wsRef.current?.readyState === WebSocket.CONNECTING + ) { + return; } - setConnectionStatus('connecting') + setConnectionStatus("connecting"); - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' - const host = window.location.host - const wsUrl = `${protocol}//${host}/api/assistant/ws/${encodeURIComponent(projectName)}` + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const host = window.location.host; + const wsUrl = `${protocol}//${host}/api/assistant/ws/${encodeURIComponent(projectName)}`; - const ws = new WebSocket(wsUrl) - wsRef.current = ws + const ws = new WebSocket(wsUrl); + wsRef.current = ws; ws.onopen = () => { - setConnectionStatus('connected') - reconnectAttempts.current = 0 + setConnectionStatus("connected"); + reconnectAttempts.current = 0; // Start ping interval to keep connection alive pingIntervalRef.current = window.setInterval(() => { if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'ping' })) + ws.send(JSON.stringify({ type: "ping" })); } - }, 30000) - } + }, 30000); + }; ws.onclose = () => { - setConnectionStatus('disconnected') + setConnectionStatus("disconnected"); if (pingIntervalRef.current) { - clearInterval(pingIntervalRef.current) - pingIntervalRef.current = null + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; } // Attempt reconnection if not intentionally closed if (reconnectAttempts.current < maxReconnectAttempts) { - reconnectAttempts.current++ - const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 10000) - reconnectTimeoutRef.current = window.setTimeout(connect, delay) + reconnectAttempts.current++; + const delay = Math.min( + 1000 * Math.pow(2, reconnectAttempts.current), + 10000, + ); + reconnectTimeoutRef.current = window.setTimeout(connect, delay); } - } + }; ws.onerror = () => { - setConnectionStatus('error') - onError?.('WebSocket connection error') - } + setConnectionStatus("error"); + onError?.("WebSocket connection error"); + }; ws.onmessage = (event) => { try { - const data = JSON.parse(event.data) as AssistantChatServerMessage + const data = JSON.parse(event.data) as AssistantChatServerMessage; switch (data.type) { - case 'text': { + case "text": { // Append text to current assistant message or create new one setMessages((prev) => { - const lastMessage = prev[prev.length - 1] - if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) { + const lastMessage = prev[prev.length - 1]; + if ( + lastMessage?.role === "assistant" && + lastMessage.isStreaming + ) { // Append to existing streaming message return [ ...prev.slice(0, -1), @@ -123,155 +132,197 @@ export function useAssistantChat({ ...lastMessage, content: lastMessage.content + data.content, }, - ] + ]; } else { // Create new assistant message - currentAssistantMessageRef.current = generateId() + currentAssistantMessageRef.current = generateId(); return [ ...prev, { id: currentAssistantMessageRef.current, - role: 'assistant', + role: "assistant", content: data.content, timestamp: new Date(), isStreaming: true, }, - ] + ]; } - }) - break + }); + break; } - case 'tool_call': { + case "tool_call": { + // Generate user-friendly tool descriptions + let toolDescription = `Using tool: ${data.tool}`; + + if (data.tool === "mcp__features__feature_create") { + const input = data.input as { name?: string; category?: string }; + toolDescription = `Creating feature: "${input.name || "New Feature"}" in ${input.category || "General"}`; + } else if (data.tool === "mcp__features__feature_create_bulk") { + const input = data.input as { + features?: Array<{ name: string }>; + }; + const count = input.features?.length || 0; + toolDescription = `Creating ${count} feature${count !== 1 ? "s" : ""}`; + } else if (data.tool === "mcp__features__feature_skip") { + toolDescription = `Skipping feature (moving to end of queue)`; + } else if (data.tool === "mcp__features__feature_get_stats") { + toolDescription = `Checking project progress`; + } else if (data.tool === "mcp__features__feature_get_next") { + toolDescription = `Getting next pending feature`; + } else if (data.tool === "Read") { + const input = data.input as { file_path?: string }; + const path = input.file_path || ""; + const filename = path.split("/").pop() || path; + toolDescription = `Reading file: ${filename}`; + } else if (data.tool === "Glob") { + const input = data.input as { pattern?: string }; + toolDescription = `Searching for files: ${input.pattern || "..."}`; + } else if (data.tool === "Grep") { + const input = data.input as { pattern?: string }; + toolDescription = `Searching for: ${input.pattern || "..."}`; + } + // Show tool call as system message setMessages((prev) => [ ...prev, { id: generateId(), - role: 'system', - content: `Using tool: ${data.tool}`, + role: "system", + content: toolDescription, timestamp: new Date(), }, - ]) - break + ]); + break; } - case 'conversation_created': { - setConversationId(data.conversation_id) - break + case "conversation_created": { + setConversationId(data.conversation_id); + break; } - case 'response_done': { - setIsLoading(false) + case "response_done": { + setIsLoading(false); // Mark current message as done streaming setMessages((prev) => { - const lastMessage = prev[prev.length - 1] - if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) { + const lastMessage = prev[prev.length - 1]; + if ( + lastMessage?.role === "assistant" && + lastMessage.isStreaming + ) { return [ ...prev.slice(0, -1), { ...lastMessage, isStreaming: false }, - ] + ]; } - return prev - }) - break + return prev; + }); + break; } - case 'error': { - setIsLoading(false) - onError?.(data.content) + case "error": { + setIsLoading(false); + onError?.(data.content); // Add error as system message setMessages((prev) => [ ...prev, { id: generateId(), - role: 'system', + role: "system", content: `Error: ${data.content}`, timestamp: new Date(), }, - ]) - break + ]); + break; } - case 'pong': { + case "pong": { // Keep-alive response, nothing to do - break + break; } } } catch (e) { - console.error('Failed to parse WebSocket message:', e) + console.error("Failed to parse WebSocket message:", e); } - } - }, [projectName, onError]) + }; + }, [projectName, onError]); - const start = useCallback((existingConversationId?: number | null) => { - connect() + const start = useCallback( + (existingConversationId?: number | null) => { + connect(); - // Wait for connection then send start message - const checkAndSend = () => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - setIsLoading(true) - const payload: { type: string; conversation_id?: number } = { type: 'start' } - if (existingConversationId) { - payload.conversation_id = existingConversationId - setConversationId(existingConversationId) + // Wait for connection then send start message + const checkAndSend = () => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + setIsLoading(true); + const payload: { type: string; conversation_id?: number } = { + type: "start", + }; + if (existingConversationId) { + payload.conversation_id = existingConversationId; + setConversationId(existingConversationId); + } + wsRef.current.send(JSON.stringify(payload)); + } else if (wsRef.current?.readyState === WebSocket.CONNECTING) { + setTimeout(checkAndSend, 100); } - wsRef.current.send(JSON.stringify(payload)) - } else if (wsRef.current?.readyState === WebSocket.CONNECTING) { - setTimeout(checkAndSend, 100) + }; + + setTimeout(checkAndSend, 100); + }, + [connect], + ); + + const sendMessage = useCallback( + (content: string) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + onError?.("Not connected"); + return; } - } - setTimeout(checkAndSend, 100) - }, [connect]) + // Add user message to chat + setMessages((prev) => [ + ...prev, + { + id: generateId(), + role: "user", + content, + timestamp: new Date(), + }, + ]); - const sendMessage = useCallback((content: string) => { - if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { - onError?.('Not connected') - return - } + setIsLoading(true); - // Add user message to chat - setMessages((prev) => [ - ...prev, - { - id: generateId(), - role: 'user', - content, - timestamp: new Date(), - }, - ]) - - setIsLoading(true) - - // Send to server - wsRef.current.send( - JSON.stringify({ - type: 'message', - content, - }) - ) - }, [onError]) + // Send to server + wsRef.current.send( + JSON.stringify({ + type: "message", + content, + }), + ); + }, + [onError], + ); const disconnect = useCallback(() => { - reconnectAttempts.current = maxReconnectAttempts // Prevent reconnection + reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnection if (pingIntervalRef.current) { - clearInterval(pingIntervalRef.current) - pingIntervalRef.current = null + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; } if (wsRef.current) { - wsRef.current.close() - wsRef.current = null + wsRef.current.close(); + wsRef.current = null; } - setConnectionStatus('disconnected') - }, []) + setConnectionStatus("disconnected"); + }, []); const clearMessages = useCallback(() => { - setMessages([]) - setConversationId(null) - }, []) + setMessages([]); + setConversationId(null); + }, []); return { messages, @@ -282,5 +333,5 @@ export function useAssistantChat({ sendMessage, disconnect, clearMessages, - } + }; }