From a5ad8bf182cb510aa97d40f25f446bf99e7465c9 Mon Sep 17 00:00:00 2001 From: mohamed zwanski Date: Thu, 22 Oct 2026 21:44:11 +0100 Subject: [PATCH] Let the assistant report bugs against existing features (#135) Add a feature_report_bug MCP tool that appends a bug report (and optional repro steps) to an existing feature and moves it back to pending, instead of creating a new 'fix X' feature. Expose it and feature_get_graph to the assistant, teach the system prompt the help / new feature / bug report flows, and add quick-action chips in the assistant chat to pick one. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + mcp_server/feature_mcp.py | 68 ++++++++++++ server/services/assistant_chat_session.py | 36 ++++++- test_feature_report_bug.py | 125 ++++++++++++++++++++++ ui/src/components/AssistantChat.tsx | 64 ++++++++++- 5 files changed, 287 insertions(+), 7 deletions(-) create mode 100644 test_feature_report_bug.py diff --git a/CLAUDE.md b/CLAUDE.md index 6f59910be..764e2a10d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -241,6 +241,7 @@ MCP tools available to the agent: - `feature_clear_in_progress` - Clear in-progress status - `feature_create_bulk` - Initialize all features (used by initializer) - `feature_create` - Create a single feature +- `feature_report_bug` - Report a bug on an existing feature and move it back to pending (used by the assistant) - `feature_add_dependency` - Add dependency between features (with cycle detection) - `feature_remove_dependency` - Remove a dependency - `feature_set_dependencies` - Set all dependencies for a feature at once diff --git a/mcp_server/feature_mcp.py b/mcp_server/feature_mcp.py index 71e217d41..d8a86b447 100755 --- a/mcp_server/feature_mcp.py +++ b/mcp_server/feature_mcp.py @@ -17,6 +17,7 @@ - feature_clear_in_progress: Clear in-progress status - feature_create_bulk: Create multiple features at once - feature_create: Create a single feature +- feature_report_bug: Report a bug on an existing feature (reopens it as pending) - feature_add_dependency: Add a dependency between features - feature_remove_dependency: Remove a dependency - feature_get_ready: Get features ready to implement @@ -689,6 +690,73 @@ def feature_create( return json.dumps({"error": str(e)}) +@mcp.tool() +def feature_report_bug( + feature_id: Annotated[int, Field(description="The ID of the existing feature the bug belongs to", ge=1)], + bug_description: Annotated[str, Field(min_length=1, description="What is broken: observed vs. expected behavior")], + steps: Annotated[list[str] | None, Field( + default=None, + description="Optional steps to reproduce the bug / verify the fix; appended to the feature's steps", + )] = None, +) -> str: + """Report a bug against an existing feature and send it back to pending. + + Use this instead of creating a new feature when the user reports that + something which was already implemented does not work correctly. The bug + report is appended to the feature's description (and any extra steps to + its steps), and the feature is marked as not passing so a coding agent + picks it up again. This keeps the backlog small and tied to the original + feature rather than accumulating "fix X" features. + + Args: + feature_id: The ID of the feature that has the bug + bug_description: Clear description of the bug (observed vs. expected) + steps: Optional reproduction/verification steps to add to the feature + + Returns: + JSON with the updated feature details, or error if not found. + """ + bug_description = bug_description.strip() + if not bug_description: + return json.dumps({"error": "bug_description must not be empty"}) + extra_steps = [s.strip() for s in (steps or []) if s and s.strip()] + + session = get_session() + try: + feature = session.query(Feature).filter(Feature.id == feature_id).first() + if feature is None: + return json.dumps({"error": f"Feature with ID {feature_id} not found"}) + + if feature.in_progress: + return json.dumps({ + "error": f"Feature #{feature_id} is currently in progress; " + "wait for the agent to finish before reporting a bug against it" + }) + + was_passing = bool(feature.passes) + feature.description = f"{feature.description}\n\n**Bug report:** {bug_description}" + if extra_steps: + # Reassign (not mutate) so SQLAlchemy detects the JSON change + feature.steps = [*(feature.steps or []), *extra_steps] + feature.passes = False + feature.in_progress = False + session.commit() + session.refresh(feature) + + status = "reopened and moved back to pending" if was_passing else "updated (still pending)" + return json.dumps({ + "success": True, + "message": f"Bug reported on feature #{feature_id} '{feature.name}': {status}", + "was_passing": was_passing, + "feature": feature.to_dict(), + }) + except Exception as e: + session.rollback() + return json.dumps({"error": f"Failed to report bug: {str(e)}"}) + finally: + session.close() + + @mcp.tool() def feature_add_dependency( feature_id: Annotated[int, Field(ge=1, description="Feature to add dependency to")], diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index 1a16606d5..56ecce9bb 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -43,13 +43,15 @@ "mcp__features__feature_get_by_id", "mcp__features__feature_get_ready", "mcp__features__feature_get_blocked", + "mcp__features__feature_get_graph", ] -# Feature management tools (create/skip but not mark_passing) +# Feature management tools (create/skip/report bugs but not mark_passing) FEATURE_MANAGEMENT_TOOLS = [ "mcp__features__feature_create", "mcp__features__feature_create_bulk", "mcp__features__feature_skip", + "mcp__features__feature_report_bug", ] # Interactive tools @@ -107,7 +109,12 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str: 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. -You have MCP tools available for feature management. Use them directly by calling the tool -- do not suggest CLI commands, bash commands, or curl commands to the user. You can create features yourself using the feature_create and feature_create_bulk tools. +You have MCP tools available for feature management. Use them directly by calling the tool -- do not suggest CLI commands, bash commands, or curl commands to the user. You can create features yourself using the feature_create and feature_create_bulk tools, and report bugs against existing features with feature_report_bug. + +You typically help with one of three kinds of requests: +1. **Help** -- questions about the codebase, features, or project progress +2. **New feature** -- something the project does not do yet (use feature_create) +3. **Bug report** -- something that was already implemented but does not work correctly (use feature_report_bug on the existing feature instead of creating a new one) ## What You CAN Do @@ -119,16 +126,17 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str: **Feature Management:** - Create new features/test cases in the backlog +- Report bugs on existing features (appends the bug to the feature and moves it back to pending) - 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 -- you have NO Write/Edit/Bash tools -- Mark features as passing or failing (that is the coding agent's job after real implementation) +- Mark features as passing (that is the coding agent's job after real implementation) - Run bash commands or execute code -**You must NEVER implement code yourself.** Even if the user pastes code, asks for a "quick fix", or insists -- do not attempt to write or edit files. Implementation is done exclusively by the coding agents. When the user wants code changed or a bug fixed, create a feature for it with `feature_create` (or `feature_create_bulk`) describing the change, then tell the user to start the coding agent to implement it. +**You must NEVER implement code yourself.** Even if the user pastes code, asks for a "quick fix", or insists -- do not attempt to write or edit files. Implementation is done exclusively by the coding agents. When the user wants code changed, create a feature for it with `feature_create` (or `feature_create_bulk`) describing the change. When the user reports a bug in something that already exists, use `feature_report_bug` on the affected feature. Then tell the user to start the coding agent to implement it. ## Project Specification @@ -147,8 +155,10 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str: - **feature_get_by_id**: Get details for a specific feature - **feature_get_ready**: See features ready for implementation - **feature_get_blocked**: See features blocked by dependencies +- **feature_get_graph**: List all features (id, name, category, status) -- use this to find the feature a bug belongs to - **feature_create**: Create a single feature in the backlog - **feature_create_bulk**: Create multiple features at once +- **feature_report_bug**: Report a bug on an existing feature; it is reopened as pending so a coding agent fixes it - **feature_skip**: Move a feature to the end of the queue **Interactive:** @@ -174,6 +184,22 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str: [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. +## Reporting Bugs + +When a user reports that something is broken, prefer fixing the existing feature over adding a new one -- this keeps the backlog small and clear: + +1. Find the feature the bug belongs to: call `feature_get_graph` to list features, then `feature_get_by_id` to confirm. Read the relevant code if it helps pinpoint the problem. +2. If it is ambiguous which feature is affected, use `ask_user` to let the user pick from the likely candidates. +3. Call `feature_report_bug` with the feature_id, a clear bug_description (observed vs. expected behavior), and optionally steps to reproduce/verify the fix. +4. Only if the bug does not belong to any existing feature, create a new feature with `feature_create` instead. +5. Tell the user which feature was reopened and that the coding agent will pick it up. + +**Example interaction:** +User: "The login form accepts an empty password" +You: [calls feature_get_graph, finds feature #12 "User login"] +[calls feature_report_bug with feature_id=12, bug_description="Login form submits with an empty password; it should show a validation error", steps=["Submit the login form with an empty password", "Verify a validation error is shown and no request is sent"]] +You: I've reported this bug on feature #12 "User login" and moved it back to pending. Start the coding agent to fix it. + ## Guidelines 1. Be concise and helpful @@ -332,7 +358,7 @@ async def start(self) -> AsyncGenerator[dict, None]: # New conversations don't need history loading self._history_loaded = True try: - greeting = f"Hello! I'm your project assistant for **{self.project_name}**. I can help you understand the codebase, explain features, and answer questions about the project. What would you like to know?" + greeting = f"Hello! I'm your project assistant for **{self.project_name}**. I can answer questions about the codebase, add new features to the backlog, or take a bug report on an existing feature. What would you like to do?" # Store the greeting in the database # conversation_id is guaranteed non-None here (set on line 206 above) diff --git a/test_feature_report_bug.py b/test_feature_report_bug.py new file mode 100644 index 000000000..aa3c6278c --- /dev/null +++ b/test_feature_report_bug.py @@ -0,0 +1,125 @@ +""" +Tests for the feature_report_bug MCP tool and its exposure to the assistant. + +Run with: python -m pytest test_feature_report_bug.py +""" + +import json + +import pytest + +import mcp_server.feature_mcp as feature_mcp +from api.database import Feature, create_database +from server.services.assistant_chat_session import ( + ASSISTANT_FEATURE_TOOLS, + DISALLOWED_ASSISTANT_TOOLS, +) + + +@pytest.fixture +def session_maker(tmp_path, monkeypatch): + _engine, maker = create_database(tmp_path) + monkeypatch.setattr(feature_mcp, "_session_maker", maker) + yield maker + _engine.dispose() + + +def _add_feature(maker, **overrides) -> int: + session = maker() + try: + feature = Feature( + priority=1, + category="Auth", + name="User login", + description="Users can log in.", + steps=["Open login page", "Submit valid credentials"], + passes=True, + in_progress=False, + ) + for key, value in overrides.items(): + setattr(feature, key, value) + session.add(feature) + session.commit() + return int(feature.id) + finally: + session.close() + + +def _get(maker, feature_id: int) -> Feature: + session = maker() + try: + feature: Feature = session.query(Feature).filter(Feature.id == feature_id).one() + session.expunge(feature) + return feature + finally: + session.close() + + +def test_reopens_passing_feature_with_bug_details(session_maker): + fid = _add_feature(session_maker) + + result = json.loads(feature_mcp.feature_report_bug( + feature_id=fid, + bug_description="Empty password is accepted", + steps=["Submit with an empty password", " ", "Verify a validation error is shown"], + )) + + assert result["success"] is True + assert result["was_passing"] is True + feature = _get(session_maker, fid) + assert feature.passes is False + assert feature.in_progress is False + assert feature.description.startswith("Users can log in.") + assert "**Bug report:** Empty password is accepted" in feature.description + assert feature.steps == [ + "Open login page", + "Submit valid credentials", + "Submit with an empty password", + "Verify a validation error is shown", + ] + # Priority is unchanged; no new feature is created + assert feature.priority == 1 + session = session_maker() + try: + assert session.query(Feature).count() == 1 + finally: + session.close() + + +def test_pending_feature_without_steps(session_maker): + fid = _add_feature(session_maker, passes=False) + + result = json.loads(feature_mcp.feature_report_bug(feature_id=fid, bug_description="Typo in label")) + + assert result["success"] is True + assert result["was_passing"] is False + feature = _get(session_maker, fid) + assert feature.steps == ["Open login page", "Submit valid credentials"] + assert "Typo in label" in feature.description + + +def test_rejects_in_progress_feature(session_maker): + fid = _add_feature(session_maker, passes=False, in_progress=True) + + result = json.loads(feature_mcp.feature_report_bug(feature_id=fid, bug_description="Broken")) + + assert "error" in result + feature = _get(session_maker, fid) + assert feature.in_progress is True + assert feature.description == "Users can log in." + + +def test_missing_feature_and_blank_description(session_maker): + assert "error" in json.loads(feature_mcp.feature_report_bug(feature_id=999, bug_description="x")) + + fid = _add_feature(session_maker) + assert "error" in json.loads(feature_mcp.feature_report_bug(feature_id=fid, bug_description=" ")) + assert _get(session_maker, fid).passes is True + + +def test_assistant_can_report_bugs_but_not_mark_status(): + assert "mcp__features__feature_report_bug" in ASSISTANT_FEATURE_TOOLS + assert "mcp__features__feature_get_graph" in ASSISTANT_FEATURE_TOOLS + assert "mcp__features__feature_report_bug" not in DISALLOWED_ASSISTANT_TOOLS + assert "mcp__features__feature_mark_passing" in DISALLOWED_ASSISTANT_TOOLS + assert "mcp__features__feature_mark_failing" in DISALLOWED_ASSISTANT_TOOLS diff --git a/ui/src/components/AssistantChat.tsx b/ui/src/components/AssistantChat.tsx index 0592644e6..36d99e182 100644 --- a/ui/src/components/AssistantChat.tsx +++ b/ui/src/components/AssistantChat.tsx @@ -7,7 +7,8 @@ */ import { useState, useRef, useEffect, useCallback, useMemo } from 'react' -import { Send, Loader2, Wifi, WifiOff, Plus, History } from 'lucide-react' +import { Send, Loader2, Wifi, WifiOff, Plus, History, HelpCircle, Sparkles, Bug } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' import { useAssistantChat } from '../hooks/useAssistantChat' import { ChatMessage as ChatMessageComponent } from './ChatMessage' import { ConversationHistory } from './ConversationHistory' @@ -17,6 +18,29 @@ import { isSubmitEnter } from '../lib/keyboard' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' +// Quick-start choices shown before the user sends their first message. +// Each one prefills the input so the user can add details before sending. +const QUICK_ACTIONS: { label: string; icon: LucideIcon; prefill: string; placeholder: string }[] = [ + { + label: 'Ask for help', + icon: HelpCircle, + prefill: 'Question: ', + placeholder: 'Ask about the codebase...', + }, + { + label: 'New feature', + icon: Sparkles, + prefill: 'New feature request: ', + placeholder: 'Describe the feature you want added...', + }, + { + label: 'Report a bug', + icon: Bug, + prefill: 'Bug report: ', + placeholder: 'Describe what is broken (which feature, observed vs. expected)...', + }, +] + interface AssistantChatProps { projectName: string conversationId?: number | null @@ -38,6 +62,7 @@ export function AssistantChat({ }: AssistantChatProps) { const [inputValue, setInputValue] = useState('') const [showHistory, setShowHistory] = useState(false) + const [placeholder, setPlaceholder] = useState(QUICK_ACTIONS[0].placeholder) const messagesEndRef = useRef(null) const inputRef = useRef(null) const hasStartedRef = useRef(false) @@ -137,6 +162,19 @@ export function AssistantChat({ setInputValue('') } + const handleQuickAction = (action: (typeof QUICK_ACTIONS)[number]) => { + setInputValue(action.prefill) + setPlaceholder(action.placeholder) + // Focus and move the caret after the prefilled text + requestAnimationFrame(() => { + const el = inputRef.current + if (el) { + el.focus() + el.setSelectionRange(el.value.length, el.value.length) + } + }) + } + const handleKeyDown = (e: React.KeyboardEvent) => { if (isSubmitEnter(e)) { e.preventDefault() @@ -170,6 +208,9 @@ export function AssistantChat({ return Array.from(messageMap.values()) }, [initialMessages, messages, conversationId, isLoadingConversation]) + const hasUserMessages = displayMessages.some((m) => m.role === 'user') + const showQuickActions = !hasUserMessages && !isLoadingConversation && !currentQuestions + return (
{/* Header with actions and connection status */} @@ -252,6 +293,25 @@ export function AssistantChat({ {displayMessages.map((message) => ( ))} + {showQuickActions && !isLoading && ( +
+ {QUICK_ACTIONS.map((action) => { + const Icon = action.icon + return ( + + ) + })} +
+ )}
)} @@ -289,7 +349,7 @@ export function AssistantChat({ value={inputValue} onChange={(e) => setInputValue(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Ask about the codebase..." + placeholder={placeholder} disabled={isLoading || isLoadingConversation || connectionStatus !== 'connected' || !!currentQuestions} className="flex-1 resize-none min-h-[44px] max-h-[120px]" rows={1}