diff --git a/codeframe/core/adapters/builtin.py b/codeframe/core/adapters/builtin.py index 33421624..5b759399 100644 --- a/codeframe/core/adapters/builtin.py +++ b/codeframe/core/adapters/builtin.py @@ -254,6 +254,7 @@ def _try_tactical_recovery(self, state, task_id: str, build_agent): self._workspace, task_id=task_id, question=f"Technical error: {error_msg[:500]}", + created_by="agent", ) blockers.answer(self._workspace, blocker.id, resolution) diff --git a/codeframe/core/adapters/verification_wrapper.py b/codeframe/core/adapters/verification_wrapper.py index a571d775..32808c6a 100644 --- a/codeframe/core/adapters/verification_wrapper.py +++ b/codeframe/core/adapters/verification_wrapper.py @@ -205,6 +205,7 @@ def _create_escalation_blocker( workspace=self._workspace, question=question, task_id=task_id, + created_by="agent", ) except Exception: logger.warning("Failed to create escalation blocker", exc_info=True) @@ -235,6 +236,7 @@ def _create_exhaustion_blocker( workspace=self._workspace, question=question, task_id=task_id, + created_by="agent", ) except Exception: logger.warning("Failed to create exhaustion blocker", exc_info=True) diff --git a/codeframe/core/agent.py b/codeframe/core/agent.py index 88e1d71b..d88da533 100644 --- a/codeframe/core/agent.py +++ b/codeframe/core/agent.py @@ -621,6 +621,7 @@ def _execute_plan(self) -> None: workspace=self.workspace, question=f"Agent aborted: {consecutive_verification_failures} consecutive verification failures at step {step.index} ({step.description}). Last error: {error_msg[:500]}", task_id=self.state.task_id, + created_by="agent", ) self.state.status = AgentStatus.BLOCKED self.state.blocker = BlockerInfo( @@ -1797,6 +1798,7 @@ def _create_blocker_from_failure( workspace=self.workspace, question=question, task_id=self.state.task_id, + created_by="agent", ) self.state.status = AgentStatus.BLOCKED @@ -1888,6 +1890,7 @@ def _create_escalation_blocker( workspace=self.workspace, question=question, task_id=self.state.task_id, + created_by="agent", ) self.state.status = AgentStatus.BLOCKED diff --git a/codeframe/core/blockers.py b/codeframe/core/blockers.py index 7e7111f1..31198232 100644 --- a/codeframe/core/blockers.py +++ b/codeframe/core/blockers.py @@ -30,6 +30,14 @@ class BlockerStatus(str, Enum): RESOLVED = "RESOLVED" +class BlockerOrigin(str, Enum): + """Who created the blocker.""" + + SYSTEM = "system" + AGENT = "agent" + HUMAN = "human" + + @dataclass class Blocker: """Represents a blocker (human-in-the-loop question). @@ -43,6 +51,7 @@ class Blocker: status: Current blocker status created_at: When the blocker was created answered_at: When the blocker was answered (if answered) + created_by: Origin of the blocker (system, agent, or human) """ id: str @@ -53,12 +62,14 @@ class Blocker: status: BlockerStatus created_at: datetime answered_at: Optional[datetime] + created_by: BlockerOrigin = BlockerOrigin.HUMAN def create( workspace: Workspace, question: str, task_id: Optional[str] = None, + created_by: str = "human", ) -> Blocker: """Create a new blocker. @@ -66,10 +77,12 @@ def create( workspace: Target workspace question: The question to ask task_id: Optional associated task ID + created_by: Origin of the blocker ("system", "agent", or "human") Returns: Created Blocker """ + origin = BlockerOrigin(created_by) blocker_id = str(uuid.uuid4()) now = _utc_now().isoformat() @@ -78,10 +91,10 @@ def create( cursor.execute( """ - INSERT INTO blockers (id, workspace_id, task_id, question, status, created_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO blockers (id, workspace_id, task_id, question, status, created_at, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?) """, - (blocker_id, workspace.id, task_id, question, BlockerStatus.OPEN.value, now), + (blocker_id, workspace.id, task_id, question, BlockerStatus.OPEN.value, now, origin.value), ) conn.commit() conn.close() @@ -95,6 +108,7 @@ def create( status=BlockerStatus.OPEN, created_at=datetime.fromisoformat(now), answered_at=None, + created_by=origin, ) # Emit blocker created event @@ -126,7 +140,8 @@ def get(workspace: Workspace, blocker_id: str) -> Optional[Blocker]: # Try exact match first cursor.execute( """ - SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at + SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at, + COALESCE(created_by, 'human') as created_by FROM blockers WHERE workspace_id = ? AND id = ? """, @@ -138,7 +153,8 @@ def get(workspace: Workspace, blocker_id: str) -> Optional[Blocker]: if not row: cursor.execute( """ - SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at + SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at, + COALESCE(created_by, 'human') as created_by FROM blockers WHERE workspace_id = ? AND id LIKE ? """, @@ -192,7 +208,8 @@ def list_all( cursor = conn.cursor() query = """ - SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at + SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at, + COALESCE(created_by, 'human') as created_by FROM blockers WHERE workspace_id = ? """ @@ -383,4 +400,5 @@ def _row_to_blocker(row: tuple) -> Blocker: status=BlockerStatus(row[5]), created_at=datetime.fromisoformat(row[6]), answered_at=datetime.fromisoformat(row[7]) if row[7] else None, + created_by=BlockerOrigin(row[8]), ) diff --git a/codeframe/core/prd_discovery.py b/codeframe/core/prd_discovery.py index f3b17559..0fc48c5f 100644 --- a/codeframe/core/prd_discovery.py +++ b/codeframe/core/prd_discovery.py @@ -547,7 +547,7 @@ def pause_discovery(self, reason: str) -> str: ) blocker = blockers.create( - self.workspace, question=question, task_id=None + self.workspace, question=question, task_id=None, created_by="system" ) self._blocker_id = blocker.id self._save_session() diff --git a/codeframe/core/react_agent.py b/codeframe/core/react_agent.py index 54e67168..e5e60524 100644 --- a/codeframe/core/react_agent.py +++ b/codeframe/core/react_agent.py @@ -1209,10 +1209,12 @@ def _create_text_blocker(self, text: str, reason: str) -> None: f"Agent detected a blocker: {reason}\n\n" f"Context:\n{text[:500]}" ) + origin = "system" if reason == "stall_detected" else "agent" blocker = blockers.create( workspace=self.workspace, question=question, task_id=self._current_task_id, + created_by=origin, ) self.blocker_id = blocker.id @@ -1232,6 +1234,7 @@ def _create_escalation_blocker( workspace=self.workspace, question=question, task_id=self._current_task_id, + created_by="agent", ) self.blocker_id = blocker.id diff --git a/codeframe/core/workspace.py b/codeframe/core/workspace.py index 465c6280..012b9877 100644 --- a/codeframe/core/workspace.py +++ b/codeframe/core/workspace.py @@ -173,12 +173,20 @@ def _init_database(db_path: Path) -> None: status TEXT NOT NULL DEFAULT 'OPEN', created_at TEXT NOT NULL, answered_at TEXT, + created_by TEXT NOT NULL DEFAULT 'human', FOREIGN KEY (workspace_id) REFERENCES workspace(id), FOREIGN KEY (task_id) REFERENCES tasks(id), - CHECK (status IN ('OPEN', 'ANSWERED', 'RESOLVED')) + CHECK (status IN ('OPEN', 'ANSWERED', 'RESOLVED')), + CHECK (created_by IN ('system', 'agent', 'human')) ) """) + # Migration: Add created_by column to existing blockers table + cursor.execute("PRAGMA table_info(blockers)") + blocker_columns = {row[1] for row in cursor.fetchall()} + if "created_by" not in blocker_columns: + cursor.execute("ALTER TABLE blockers ADD COLUMN created_by TEXT NOT NULL DEFAULT 'human'") + # Checkpoints (state snapshots) cursor.execute(""" CREATE TABLE IF NOT EXISTS checkpoints ( diff --git a/codeframe/ui/routers/blockers_v2.py b/codeframe/ui/routers/blockers_v2.py index 72d6b0c8..c4051eec 100644 --- a/codeframe/ui/routers/blockers_v2.py +++ b/codeframe/ui/routers/blockers_v2.py @@ -45,6 +45,7 @@ class BlockerResponse(BaseModel): status: str created_at: str answered_at: Optional[str] + created_by: str = "human" class BlockerListResponse(BaseModel): @@ -84,6 +85,7 @@ def _blocker_to_response(blocker: blockers.Blocker) -> BlockerResponse: status=blocker.status.value, created_at=blocker.created_at.isoformat(), answered_at=blocker.answered_at.isoformat() if blocker.answered_at else None, + created_by=blocker.created_by.value, ) diff --git a/tests/core/test_blocker_origin.py b/tests/core/test_blocker_origin.py new file mode 100644 index 00000000..f7e90f4d --- /dev/null +++ b/tests/core/test_blocker_origin.py @@ -0,0 +1,84 @@ +"""Tests for blocker origin (created_by) field — issue #487.""" + +import pytest +from pathlib import Path + +from codeframe.core.workspace import create_or_load_workspace +from codeframe.core import blockers +from codeframe.core.blockers import BlockerOrigin + +pytestmark = pytest.mark.v2 + + +@pytest.fixture +def workspace(tmp_path: Path): + repo = tmp_path / "repo" + repo.mkdir() + return create_or_load_workspace(repo) + + +class TestBlockerOriginEnum: + def test_valid_values(self): + assert BlockerOrigin.SYSTEM == "system" + assert BlockerOrigin.AGENT == "agent" + assert BlockerOrigin.HUMAN == "human" + + +class TestBlockerCreate: + def test_invalid_origin_raises_value_error(self, workspace): + with pytest.raises(ValueError): + blockers.create(workspace, "A question?", created_by="invalid") + + def test_default_origin_is_human(self, workspace): + b = blockers.create(workspace, "A question?") + assert b.created_by == BlockerOrigin.HUMAN + + def test_agent_origin(self, workspace): + b = blockers.create(workspace, "Agent question?", created_by="agent") + assert b.created_by == BlockerOrigin.AGENT + + def test_system_origin(self, workspace): + b = blockers.create(workspace, "Stall detected", created_by="system") + assert b.created_by == BlockerOrigin.SYSTEM + + def test_origin_persisted_and_retrieved(self, workspace): + created = blockers.create(workspace, "Test?", created_by="agent") + fetched = blockers.get(workspace, created.id) + assert fetched is not None + assert fetched.created_by == BlockerOrigin.AGENT + + +class TestBlockerListIncludesOrigin: + def test_list_all_includes_created_by(self, workspace): + blockers.create(workspace, "Q1", created_by="human") + blockers.create(workspace, "Q2", created_by="agent") + result = blockers.list_all(workspace) + assert len(result) == 2 + origins = {b.created_by for b in result} + assert BlockerOrigin.HUMAN in origins + assert BlockerOrigin.AGENT in origins + + +class TestExistingBlockersMigration: + def test_blockers_without_created_by_default_to_human(self, workspace): + """Simulate a pre-migration blocker row with no created_by value.""" + from codeframe.core.workspace import get_db_connection + import uuid + from datetime import datetime, timezone + + conn = get_db_connection(workspace) + old_id = str(uuid.uuid4()) + now = datetime.now(timezone.utc).isoformat() + conn.execute( + """ + INSERT INTO blockers (id, workspace_id, task_id, question, status, created_at) + VALUES (?, ?, NULL, ?, 'OPEN', ?) + """, + (old_id, workspace.id, "Old question?", now), + ) + conn.commit() + conn.close() + + fetched = blockers.get(workspace, old_id) + assert fetched is not None + assert fetched.created_by == BlockerOrigin.HUMAN diff --git a/web-ui/__mocks__/@hugeicons/react.js b/web-ui/__mocks__/@hugeicons/react.js index e475e5c2..6505e9ba 100644 --- a/web-ui/__mocks__/@hugeicons/react.js +++ b/web-ui/__mocks__/@hugeicons/react.js @@ -65,4 +65,7 @@ module.exports = { // FileTreePanel / DiffViewer FileAddIcon: createIconMock('FileAddIcon'), FileRemoveIcon: createIconMock('FileRemoveIcon'), + // BlockerCard origin badges + Settings01Icon: createIconMock('Settings01Icon'), + UserCircle02Icon: createIconMock('UserCircle02Icon'), }; diff --git a/web-ui/__tests__/components/blockers/BlockerCard.test.tsx b/web-ui/__tests__/components/blockers/BlockerCard.test.tsx index 6f541d39..b87adad6 100644 --- a/web-ui/__tests__/components/blockers/BlockerCard.test.tsx +++ b/web-ui/__tests__/components/blockers/BlockerCard.test.tsx @@ -23,6 +23,7 @@ function makeBlocker(overrides: Partial = {}): Blocker { status: 'OPEN', created_at: new Date(Date.now() - 30 * 60 * 1000).toISOString(), // 30m ago answered_at: null, + created_by: 'human', ...overrides, }; } @@ -217,4 +218,48 @@ describe('BlockerCard', () => { expect(screen.getByLabelText('Your answer to the blocker question')).toBeInTheDocument(); }); + + describe('origin badge', () => { + it('shows Manual badge for human-created blocker', () => { + render( + + ); + expect(screen.getByTestId('origin-badge')).toHaveTextContent('Manual'); + }); + + it('shows Agent badge for agent-created blocker', () => { + render( + + ); + expect(screen.getByTestId('origin-badge')).toHaveTextContent('Agent'); + }); + + it('shows System badge for system-created blocker', () => { + render( + + ); + expect(screen.getByTestId('origin-badge')).toHaveTextContent('System'); + }); + + it('shows correct guidance for human blocker', () => { + render( + + ); + expect(screen.getByText('Manually created blocker. Resolve and mark answered.')).toBeInTheDocument(); + }); + + it('shows correct guidance for agent blocker', () => { + render( + + ); + expect(screen.getByText('Agent requested information. Provide the answer below.')).toBeInTheDocument(); + }); + + it('shows correct guidance for system blocker', () => { + render( + + ); + expect(screen.getByText('Agent was inactive for too long. Answer to continue or retry execution.')).toBeInTheDocument(); + }); + }); }); diff --git a/web-ui/src/components/blockers/BlockerCard.tsx b/web-ui/src/components/blockers/BlockerCard.tsx index 4d9546ba..17eb336c 100644 --- a/web-ui/src/components/blockers/BlockerCard.tsx +++ b/web-ui/src/components/blockers/BlockerCard.tsx @@ -2,13 +2,46 @@ import { useState } from 'react'; import Link from 'next/link'; -import { Alert02Icon, Loading03Icon, CheckmarkCircle01Icon } from '@hugeicons/react'; +import { + Alert02Icon, + Loading03Icon, + CheckmarkCircle01Icon, + ArtificialIntelligence01Icon, + Settings01Icon, + UserCircle02Icon, +} from '@hugeicons/react'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { blockersApi } from '@/lib/api'; import { formatRelativeTime } from '@/lib/format'; -import type { Blocker, ApiError } from '@/types'; +import type { Blocker, BlockerOrigin, ApiError } from '@/types'; + +const ORIGIN_CONFIG: Record; + badgeClass: string; + guidance: string; +}> = { + system: { + label: 'System', + Icon: Settings01Icon, + badgeClass: 'bg-blue-100 text-blue-800 dark:bg-blue-950/40 dark:text-blue-300', + guidance: 'Agent was inactive for too long. Answer to continue or retry execution.', + }, + agent: { + label: 'Agent', + Icon: ArtificialIntelligence01Icon, + badgeClass: 'bg-amber-100 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300', + guidance: 'Agent requested information. Provide the answer below.', + }, + human: { + label: 'Manual', + Icon: UserCircle02Icon, + badgeClass: 'bg-purple-100 text-purple-800 dark:bg-purple-950/40 dark:text-purple-300', + guidance: 'Manually created blocker. Resolve and mark answered.', + }, +}; interface BlockerCardProps { blocker: Blocker; @@ -24,6 +57,7 @@ export function BlockerCard({ blocker, workspacePath, onAnswered }: BlockerCardP const [collapsed, setCollapsed] = useState(false); const isOpen = blocker.status === 'OPEN'; + const origin = ORIGIN_CONFIG[blocker.created_by ?? 'human']; const handleSubmit = async () => { if (!answer.trim() || isSubmitting) return; @@ -85,10 +119,20 @@ export function BlockerCard({ blocker, workspacePath, onAnswered }: BlockerCardP {/* Metadata row */}
OPEN + + + {origin.label} + {formatRelativeTime(blocker.created_at)}
+ + {/* Origin-specific guidance */} +

{origin.guidance}

diff --git a/web-ui/src/types/index.ts b/web-ui/src/types/index.ts index 2113d1dd..cfe45e4e 100644 --- a/web-ui/src/types/index.ts +++ b/web-ui/src/types/index.ts @@ -103,6 +103,7 @@ export interface TaskStartResponse { // Blocker types // Must match backend: codeframe/ui/routers/blockers_v2.py export type BlockerStatus = 'OPEN' | 'ANSWERED' | 'RESOLVED'; +export type BlockerOrigin = 'system' | 'agent' | 'human'; export interface Blocker { id: string; @@ -113,6 +114,7 @@ export interface Blocker { status: BlockerStatus; created_at: string; answered_at: string | null; + created_by: BlockerOrigin; } export interface BlockerListResponse {