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 <noreply@anthropic.com>
This commit is contained in:
Connor Tyndall
2026-01-09 06:10:43 -06:00
parent 122f03dc21
commit 398c9d492f
3 changed files with 302 additions and 154 deletions

52
mcp_server/feature_mcp.py Normal file → Executable file
View File

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

67
server/services/assistant_chat_session.py Normal file → Executable file
View File

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

295
ui/src/hooks/useAssistantChat.ts Normal file → Executable file
View File

@@ -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<ChatMessage[]>([])
const [isLoading, setIsLoading] = useState(false)
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected')
const [conversationId, setConversationId] = useState<number | null>(null)
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("disconnected");
const [conversationId, setConversationId] = useState<number | null>(null);
const wsRef = useRef<WebSocket | null>(null)
const currentAssistantMessageRef = useRef<string | null>(null)
const reconnectAttempts = useRef(0)
const maxReconnectAttempts = 3
const pingIntervalRef = useRef<number | null>(null)
const reconnectTimeoutRef = useRef<number | null>(null)
const wsRef = useRef<WebSocket | null>(null);
const currentAssistantMessageRef = useRef<string | null>(null);
const reconnectAttempts = useRef(0);
const maxReconnectAttempts = 3;
const pingIntervalRef = useRef<number | null>(null);
const reconnectTimeoutRef = useRef<number | null>(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' }))
}
}, 30000)
ws.send(JSON.stringify({ type: "ping" }));
}
}, 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,114 +132,154 @@ 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": {
// 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 || "..."}`;
}
case 'tool_call': {
// 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' }
setIsLoading(true);
const payload: { type: string; conversation_id?: number } = {
type: "start",
};
if (existingConversationId) {
payload.conversation_id = existingConversationId
setConversationId(existingConversationId)
payload.conversation_id = existingConversationId;
setConversationId(existingConversationId);
}
wsRef.current.send(JSON.stringify(payload))
wsRef.current.send(JSON.stringify(payload));
} else if (wsRef.current?.readyState === WebSocket.CONNECTING) {
setTimeout(checkAndSend, 100)
}
setTimeout(checkAndSend, 100);
}
};
setTimeout(checkAndSend, 100)
}, [connect])
setTimeout(checkAndSend, 100);
},
[connect],
);
const sendMessage = useCallback((content: string) => {
const sendMessage = useCallback(
(content: string) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
onError?.('Not connected')
return
onError?.("Not connected");
return;
}
// Add user message to chat
@@ -238,40 +287,42 @@ export function useAssistantChat({
...prev,
{
id: generateId(),
role: 'user',
role: "user",
content,
timestamp: new Date(),
},
])
]);
setIsLoading(true)
setIsLoading(true);
// Send to server
wsRef.current.send(
JSON.stringify({
type: 'message',
type: "message",
content,
})
)
}, [onError])
}),
);
},
[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,
}
};
}