diff --git a/codeframe/core/blockers.py b/codeframe/core/blockers.py index 31198232..f00bdb63 100644 --- a/codeframe/core/blockers.py +++ b/codeframe/core/blockers.py @@ -10,7 +10,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from enum import Enum -from typing import Optional +from typing import Optional, Union from codeframe.core.workspace import Workspace, get_db_connection from codeframe.core import events, runtime, tasks @@ -69,7 +69,7 @@ def create( workspace: Workspace, question: str, task_id: Optional[str] = None, - created_by: str = "human", + created_by: Union[BlockerOrigin, str] = BlockerOrigin.HUMAN, ) -> Blocker: """Create a new blocker. diff --git a/codeframe/core/react_agent.py b/codeframe/core/react_agent.py index e5e60524..4978824c 100644 --- a/codeframe/core/react_agent.py +++ b/codeframe/core/react_agent.py @@ -51,6 +51,10 @@ PRESERVE_RECENT_PAIRS = 5 DEFAULT_CONTEXT_WINDOW = 200_000 # All Claude 4.x models +# Reason string emitted when a stall timeout triggers a blocker — used to +# set the correct BlockerOrigin ("system") vs agent-generated blockers. +_REASON_STALL_DETECTED = "stall_detected" + # Map tool names to agent phases for progress reporting. _TOOL_PHASE_MAP = { "read_file": AgentPhase.EXPLORING, @@ -220,7 +224,7 @@ def run(self, task_id: str) -> AgentStatus: try: status = self._react_loop(system_prompt) if status == AgentStatus.FAILED: - reason = "stall_detected" if self._stall_triggered.is_set() else "max_iterations_reached" + reason = _REASON_STALL_DETECTED if self._stall_triggered.is_set() else "max_iterations_reached" self._emit(EventType.AGENT_FAILED, { "task_id": task_id, "reason": reason, @@ -244,7 +248,7 @@ def run(self, task_id: str) -> AgentStatus: self._emit_stream_completion(task_id) return AgentStatus.COMPLETED - if reason == "escalated_to_blocker" or reason == "stall_detected": + if reason == "escalated_to_blocker" or reason == _REASON_STALL_DETECTED: self._emit(EventType.AGENT_FAILED, { "task_id": task_id, "reason": "blocked", @@ -433,7 +437,7 @@ def _react_loop(self, system_prompt: str) -> AgentStatus: # StallAction.BLOCKER (default) self._create_text_blocker( stall_ctx or "Agent stalled with no tool activity", - "stall_detected", + _REASON_STALL_DETECTED, ) return AgentStatus.BLOCKED @@ -689,7 +693,7 @@ def _run_final_verification( ) elif self._stall_action == StallAction.FAIL: return (False, "stall_failed") - return (False, "stall_detected") + return (False, _REASON_STALL_DETECTED) self._verbose_print("[ReactAgent] Running final verification...") self._emit_progress( @@ -1209,7 +1213,7 @@ 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" + origin = "system" if reason == _REASON_STALL_DETECTED else "agent" blocker = blockers.create( workspace=self.workspace, question=question, diff --git a/codeframe/ui/routers/blockers_v2.py b/codeframe/ui/routers/blockers_v2.py index c4051eec..3170a834 100644 --- a/codeframe/ui/routers/blockers_v2.py +++ b/codeframe/ui/routers/blockers_v2.py @@ -20,7 +20,7 @@ from codeframe.core.workspace import Workspace from codeframe.lib.rate_limiter import rate_limit_standard from codeframe.core import blockers -from codeframe.core.blockers import BlockerStatus +from codeframe.core.blockers import BlockerStatus, BlockerOrigin from codeframe.ui.dependencies import get_v2_workspace from codeframe.ui.response_models import api_error, ErrorCodes @@ -45,7 +45,7 @@ class BlockerResponse(BaseModel): status: str created_at: str answered_at: Optional[str] - created_by: str = "human" + created_by: BlockerOrigin = BlockerOrigin.HUMAN class BlockerListResponse(BaseModel): diff --git a/tests/core/test_blocker_origin.py b/tests/core/test_blocker_origin.py index f7e90f4d..ed54e23c 100644 --- a/tests/core/test_blocker_origin.py +++ b/tests/core/test_blocker_origin.py @@ -61,7 +61,7 @@ def test_list_all_includes_created_by(self, workspace): class TestExistingBlockersMigration: def test_blockers_without_created_by_default_to_human(self, workspace): - """Simulate a pre-migration blocker row with no created_by value.""" + """COALESCE fallback: rows inserted without created_by read back as HUMAN.""" from codeframe.core.workspace import get_db_connection import uuid from datetime import datetime, timezone @@ -82,3 +82,48 @@ def test_blockers_without_created_by_default_to_human(self, workspace): fetched = blockers.get(workspace, old_id) assert fetched is not None assert fetched.created_by == BlockerOrigin.HUMAN + + def test_alter_table_migration_adds_created_by_column(self, tmp_path: Path): + """ALTER TABLE migration: initializing a DB without created_by column adds it.""" + import sqlite3 + from codeframe.core.workspace import _init_database, CODEFRAME_DIR, STATE_DB_NAME + + # Create a DB with the old blockers schema (no created_by column) + repo = tmp_path / "old-repo" + repo.mkdir() + codeframe_dir = repo / CODEFRAME_DIR + codeframe_dir.mkdir() + db_path = codeframe_dir / STATE_DB_NAME + + conn = sqlite3.connect(str(db_path)) + conn.execute(""" + CREATE TABLE IF NOT EXISTS blockers ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + task_id TEXT, + question TEXT NOT NULL, + answer TEXT, + status TEXT NOT NULL DEFAULT 'OPEN', + created_at TEXT NOT NULL, + answered_at TEXT + ) + """) + conn.commit() + conn.close() + + # Confirm column is absent before migration + conn = sqlite3.connect(str(db_path)) + cursor = conn.execute("PRAGMA table_info(blockers)") + columns_before = {row[1] for row in cursor.fetchall()} + conn.close() + assert "created_by" not in columns_before + + # Run the full init (triggers the ALTER TABLE migration) + _init_database(db_path) + + # Confirm column is present after migration + conn = sqlite3.connect(str(db_path)) + cursor = conn.execute("PRAGMA table_info(blockers)") + columns_after = {row[1] for row in cursor.fetchall()} + conn.close() + assert "created_by" in columns_after diff --git a/web-ui/__tests__/components/blockers/BlockerCard.test.tsx b/web-ui/__tests__/components/blockers/BlockerCard.test.tsx index b87adad6..963e4cc4 100644 --- a/web-ui/__tests__/components/blockers/BlockerCard.test.tsx +++ b/web-ui/__tests__/components/blockers/BlockerCard.test.tsx @@ -259,7 +259,7 @@ describe('BlockerCard', () => { render( ); - expect(screen.getByText('Agent was inactive for too long. Answer to continue or retry execution.')).toBeInTheDocument(); + expect(screen.getByText('System-initiated pause. Review the context and answer to continue.')).toBeInTheDocument(); }); }); }); diff --git a/web-ui/src/components/blockers/BlockerCard.tsx b/web-ui/src/components/blockers/BlockerCard.tsx index 17eb336c..9e87ad30 100644 --- a/web-ui/src/components/blockers/BlockerCard.tsx +++ b/web-ui/src/components/blockers/BlockerCard.tsx @@ -27,7 +27,7 @@ const ORIGIN_CONFIG: Record