From 609cb37ba94215823ed5b74dda01b99505d52e04 Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 15 Feb 2026 20:50:06 -0700 Subject: [PATCH 1/3] feat(api): add engine parameter to task execution endpoints (#354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the engine parameter ("plan" or "react") through the v2 API layer so ReactAgent can be invoked via HTTP, not just CLI. - Add engine field to StartExecutionRequest and ApproveTasksRequest - Add engine validation (model_validator + HTTPException for query param) - Pass engine to conductor.start_batch() and runtime.execute_agent() - Fix bug: retry_count → max_retries parameter name in /execute endpoint - Add engine to StartExecutionResponse for confirmation --- codeframe/ui/routers/tasks_v2.py | 43 ++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/codeframe/ui/routers/tasks_v2.py b/codeframe/ui/routers/tasks_v2.py index 6e660880..832fb716 100644 --- a/codeframe/ui/routers/tasks_v2.py +++ b/codeframe/ui/routers/tasks_v2.py @@ -20,7 +20,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request from fastapi.responses import StreamingResponse -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from codeframe.core.workspace import Workspace from codeframe.lib.rate_limiter import rate_limit_ai, rate_limit_standard @@ -51,6 +51,17 @@ class ApproveTasksRequest(BaseModel): default=False, description="Whether to start batch execution after approval", ) + engine: str = Field( + "plan", + description="Execution engine: 'plan' (default) or 'react' (ReAct loop)", + ) + + @model_validator(mode="after") + def _validate_engine(self) -> "ApproveTasksRequest": + valid = ("plan", "react") + if self.engine not in valid: + raise ValueError(f"engine must be one of: {', '.join(valid)}") + return self class ApproveTasksResponse(BaseModel): @@ -97,6 +108,17 @@ class StartExecutionRequest(BaseModel): le=5, description="Number of retries for failed tasks", ) + engine: str = Field( + "plan", + description="Execution engine: 'plan' (default) or 'react' (ReAct loop)", + ) + + @model_validator(mode="after") + def _validate_engine(self) -> "StartExecutionRequest": + valid = ("plan", "react") + if self.engine not in valid: + raise ValueError(f"engine must be one of: {', '.join(valid)}") + return self class StartExecutionResponse(BaseModel): @@ -106,6 +128,7 @@ class StartExecutionResponse(BaseModel): batch_id: str task_count: int strategy: str + engine: str message: str @@ -423,6 +446,7 @@ async def approve_tasks_endpoint( strategy="serial", max_parallel=4, on_failure="continue", + engine=body.engine, ) batch_id = batch.id message = f"Approved {result.approved_count} task(s) and started execution (batch {batch_id[:8]})." @@ -530,8 +554,9 @@ async def start_execution( task_ids=task_ids, strategy=body.strategy, max_parallel=body.max_parallel, - retry_count=body.retry_count, + max_retries=body.retry_count, on_failure="continue", + engine=body.engine, ) return StartExecutionResponse( @@ -539,6 +564,7 @@ async def start_execution( batch_id=batch.id, task_count=len(task_ids), strategy=body.strategy, + engine=body.engine, message=f"Started execution for {len(task_ids)} task(s) (batch {batch.id[:8]}).", ) @@ -560,6 +586,7 @@ async def start_single_task( execute: bool = Query(False, description="Run agent execution (requires ANTHROPIC_API_KEY)"), dry_run: bool = Query(False, description="Preview changes without making them"), verbose: bool = Query(False, description="Show detailed progress output"), + engine: str = Query("plan", description="Execution engine: 'plan' (default) or 'react' (ReAct loop)"), workspace: Workspace = Depends(get_v2_workspace), ) -> dict[str, Any]: """Start a single task run. @@ -584,6 +611,17 @@ async def start_single_task( - 404: Task not found - 500: Execution error """ + valid_engines = ("plan", "react") + if engine not in valid_engines: + raise HTTPException( + status_code=400, + detail=api_error( + "Invalid engine", + ErrorCodes.VALIDATION_ERROR, + f"Engine must be one of: {', '.join(valid_engines)}", + ), + ) + try: # Start the run run = runtime.start_task_run(workspace, task_id) @@ -610,6 +648,7 @@ def _run_agent(): dry_run=dry_run, verbose=verbose, event_publisher=publisher, + engine=engine, ) except Exception as exc: logger.error(f"Background agent failed for task {task_id}: {exc}", exc_info=True) From 0c325d52e7699d8447e0c3d6fcf6d8ebdfe18431 Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 15 Feb 2026 20:54:25 -0700 Subject: [PATCH 2/3] test(api): add integration tests for engine parameter (#354) Test engine parameter validation, default values, and passthrough on all three execution endpoints: /execute, /approve, and /{id}/start. --- tests/integration/test_tasks_v2_engine.py | 313 ++++++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 tests/integration/test_tasks_v2_engine.py diff --git a/tests/integration/test_tasks_v2_engine.py b/tests/integration/test_tasks_v2_engine.py new file mode 100644 index 00000000..72ad9a09 --- /dev/null +++ b/tests/integration/test_tasks_v2_engine.py @@ -0,0 +1,313 @@ +"""Integration tests for engine parameter on task execution endpoints. + +Tests that the engine parameter is correctly validated, defaulted, and +passed through to conductor.start_batch() and runtime.execute_agent() +on all three execution endpoints: +- POST /api/v2/tasks/execute +- POST /api/v2/tasks/approve +- POST /api/v2/tasks/{task_id}/start +""" + +import uuid +from datetime import datetime, timezone +from unittest.mock import patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from codeframe.core.conductor import BatchRun, BatchStatus, OnFailure +from codeframe.core.runtime import ApprovalResult, AssignmentResult, Run, RunStatus +from codeframe.core.state_machine import TaskStatus +from codeframe.core.workspace import create_or_load_workspace +from codeframe.ui.routers import tasks_v2 + +pytestmark = pytest.mark.v2 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_app() -> FastAPI: + """Create a minimal FastAPI app with just the tasks_v2 router.""" + app = FastAPI() + app.include_router(tasks_v2.router) + return app + + +def _make_workspace(tmp_path): + """Create a v2 workspace in a temp directory.""" + return create_or_load_workspace(tmp_path) + + +def _make_task(workspace, title="Test task", status=TaskStatus.READY): + """Create a task in the workspace with the given status.""" + from codeframe.core import tasks + + task = tasks.create(workspace, title=title, description="test desc", status=TaskStatus.BACKLOG) + if status != TaskStatus.BACKLOG: + tasks.update_status(workspace, task.id, status) + return task + + +def _make_batch_run(workspace_id, task_ids, engine="plan"): + """Build a BatchRun stub for mock return values.""" + return BatchRun( + id=str(uuid.uuid4()), + workspace_id=workspace_id, + task_ids=task_ids, + status=BatchStatus.RUNNING, + strategy="serial", + max_parallel=4, + on_failure=OnFailure.CONTINUE, + started_at=datetime.now(timezone.utc), + completed_at=None, + results={}, + engine=engine, + ) + + +def _make_run(workspace_id, task_id): + """Build a Run stub for mock return values.""" + return Run( + id=str(uuid.uuid4()), + workspace_id=workspace_id, + task_id=task_id, + status=RunStatus.RUNNING, + started_at=datetime.now(timezone.utc), + completed_at=None, + ) + + +def _assignment_ok(): + """Return an AssignmentResult that allows assignment.""" + return AssignmentResult( + pending_count=1, + executing_count=0, + can_assign=True, + reason="Tasks available", + ) + + +@pytest.fixture() +def client(): + """Lightweight TestClient with just the tasks_v2 router.""" + app = _make_app() + with TestClient(app) as c: + yield c + + +# --------------------------------------------------------------------------- +# POST /api/v2/tasks/execute +# --------------------------------------------------------------------------- + + +class TestExecuteEndpointEngine: + """Tests for POST /api/v2/tasks/execute engine parameter.""" + + def test_execute_default_engine(self, tmp_path, client): + """Default engine should be 'plan' when not specified.""" + ws = _make_workspace(tmp_path) + task = _make_task(ws) + + with ( + patch("codeframe.ui.routers.tasks_v2.runtime.check_assignment_status", return_value=_assignment_ok()), + patch("codeframe.ui.routers.tasks_v2.runtime.get_ready_task_ids", return_value=[task.id]), + patch("codeframe.ui.routers.tasks_v2.conductor.start_batch", return_value=_make_batch_run(ws.id, [task.id])) as mock_batch, + ): + resp = client.post( + "/api/v2/tasks/execute", + json={"task_ids": [task.id]}, + params={"workspace_path": str(ws.repo_path)}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + mock_batch.assert_called_once() + _, kwargs = mock_batch.call_args + assert kwargs["engine"] == "plan" + + def test_execute_with_react_engine(self, tmp_path, client): + """Passing engine='react' should forward it to conductor.""" + ws = _make_workspace(tmp_path) + task = _make_task(ws) + + with ( + patch("codeframe.ui.routers.tasks_v2.runtime.check_assignment_status", return_value=_assignment_ok()), + patch("codeframe.ui.routers.tasks_v2.runtime.get_ready_task_ids", return_value=[task.id]), + patch("codeframe.ui.routers.tasks_v2.conductor.start_batch", return_value=_make_batch_run(ws.id, [task.id], engine="react")) as mock_batch, + ): + resp = client.post( + "/api/v2/tasks/execute", + json={"task_ids": [task.id], "engine": "react"}, + params={"workspace_path": str(ws.repo_path)}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + mock_batch.assert_called_once() + _, kwargs = mock_batch.call_args + assert kwargs["engine"] == "react" + + def test_execute_invalid_engine(self, tmp_path, client): + """Invalid engine value should return 422 (Pydantic validation).""" + ws = _make_workspace(tmp_path) + + resp = client.post( + "/api/v2/tasks/execute", + json={"engine": "invalid"}, + params={"workspace_path": str(ws.repo_path)}, + ) + + assert resp.status_code == 422 + + def test_engine_response_field(self, tmp_path, client): + """Response body should include the engine field.""" + ws = _make_workspace(tmp_path) + task = _make_task(ws) + + with ( + patch("codeframe.ui.routers.tasks_v2.runtime.check_assignment_status", return_value=_assignment_ok()), + patch("codeframe.ui.routers.tasks_v2.runtime.get_ready_task_ids", return_value=[task.id]), + patch("codeframe.ui.routers.tasks_v2.conductor.start_batch", return_value=_make_batch_run(ws.id, [task.id], engine="react")), + ): + resp = client.post( + "/api/v2/tasks/execute", + json={"task_ids": [task.id], "engine": "react"}, + params={"workspace_path": str(ws.repo_path)}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["engine"] == "react" + + +# --------------------------------------------------------------------------- +# POST /api/v2/tasks/{task_id}/start +# --------------------------------------------------------------------------- + + +class TestStartSingleTaskEngine: + """Tests for POST /api/v2/tasks/{task_id}/start engine parameter.""" + + def test_start_single_default_engine(self, tmp_path, client): + """Default engine should be 'plan' when query param not provided.""" + ws = _make_workspace(tmp_path) + task = _make_task(ws) + run = _make_run(ws.id, task.id) + + with patch("codeframe.ui.routers.tasks_v2.runtime.start_task_run", return_value=run): + resp = client.post( + f"/api/v2/tasks/{task.id}/start", + params={"workspace_path": str(ws.repo_path)}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + + def test_start_single_with_react_engine(self, tmp_path, client): + """Passing engine=react should forward to runtime.execute_agent().""" + import time + + ws = _make_workspace(tmp_path) + task = _make_task(ws) + run = _make_run(ws.id, task.id) + + with ( + patch("codeframe.ui.routers.tasks_v2.runtime.start_task_run", return_value=run), + patch("codeframe.ui.routers.tasks_v2.runtime.execute_agent") as mock_exec, + ): + resp = client.post( + f"/api/v2/tasks/{task.id}/start", + params={ + "workspace_path": str(ws.repo_path), + "execute": "true", + "engine": "react", + }, + ) + + assert resp.status_code == 200 + # Give background thread a moment to invoke mock + time.sleep(0.5) + mock_exec.assert_called_once() + _, kwargs = mock_exec.call_args + assert kwargs["engine"] == "react" + + def test_start_single_invalid_engine(self, tmp_path, client): + """Invalid engine value in query param should return 400.""" + ws = _make_workspace(tmp_path) + task = _make_task(ws) + + resp = client.post( + f"/api/v2/tasks/{task.id}/start", + params={ + "workspace_path": str(ws.repo_path), + "engine": "invalid", + }, + ) + + assert resp.status_code == 400 + data = resp.json() + assert "detail" in data + + +# --------------------------------------------------------------------------- +# POST /api/v2/tasks/approve +# --------------------------------------------------------------------------- + + +class TestApproveEndpointEngine: + """Tests for POST /api/v2/tasks/approve engine parameter.""" + + def test_approve_with_engine(self, tmp_path, client): + """Engine should be passed to conductor when start_execution=true.""" + ws = _make_workspace(tmp_path) + task = _make_task(ws, status=TaskStatus.BACKLOG) + + approval = ApprovalResult( + approved_count=1, + excluded_count=0, + approved_task_ids=[task.id], + excluded_task_ids=[], + ) + + with ( + patch("codeframe.ui.routers.tasks_v2.runtime.approve_tasks", return_value=approval), + patch("codeframe.ui.routers.tasks_v2.conductor.start_batch", return_value=_make_batch_run(ws.id, [task.id], engine="react")) as mock_batch, + ): + resp = client.post( + "/api/v2/tasks/approve", + json={ + "start_execution": True, + "engine": "react", + }, + params={"workspace_path": str(ws.repo_path)}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["batch_id"] is not None + mock_batch.assert_called_once() + _, kwargs = mock_batch.call_args + assert kwargs["engine"] == "react" + + def test_approve_invalid_engine(self, tmp_path, client): + """Invalid engine in approve request should return 422.""" + ws = _make_workspace(tmp_path) + + resp = client.post( + "/api/v2/tasks/approve", + json={ + "start_execution": True, + "engine": "invalid", + }, + params={"workspace_path": str(ws.repo_path)}, + ) + + assert resp.status_code == 422 From 2a5f97a4fc9d362c88a37fd71794783ac3cff42d Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 15 Feb 2026 21:08:18 -0700 Subject: [PATCH 3/3] refactor(api): address review feedback for engine parameter (#354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use Literal["plan", "react"] for query param instead of manual validation — auto-documents in OpenAPI, consistent 422 across all endpoints - Bump background thread sleep from 0.5s to 1.0s for CI stability - Add edge case test: approve with start_execution=False skips batch --- codeframe/ui/routers/tasks_v2.py | 15 ++------- tests/integration/test_tasks_v2_engine.py | 38 ++++++++++++++++++++--- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/codeframe/ui/routers/tasks_v2.py b/codeframe/ui/routers/tasks_v2.py index 832fb716..875edbba 100644 --- a/codeframe/ui/routers/tasks_v2.py +++ b/codeframe/ui/routers/tasks_v2.py @@ -16,7 +16,7 @@ import logging import threading -from typing import Any, Optional +from typing import Any, Literal, Optional from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request from fastapi.responses import StreamingResponse @@ -586,7 +586,7 @@ async def start_single_task( execute: bool = Query(False, description="Run agent execution (requires ANTHROPIC_API_KEY)"), dry_run: bool = Query(False, description="Preview changes without making them"), verbose: bool = Query(False, description="Show detailed progress output"), - engine: str = Query("plan", description="Execution engine: 'plan' (default) or 'react' (ReAct loop)"), + engine: Literal["plan", "react"] = Query("plan", description="Execution engine: 'plan' (default) or 'react' (ReAct loop)"), workspace: Workspace = Depends(get_v2_workspace), ) -> dict[str, Any]: """Start a single task run. @@ -611,17 +611,6 @@ async def start_single_task( - 404: Task not found - 500: Execution error """ - valid_engines = ("plan", "react") - if engine not in valid_engines: - raise HTTPException( - status_code=400, - detail=api_error( - "Invalid engine", - ErrorCodes.VALIDATION_ERROR, - f"Engine must be one of: {', '.join(valid_engines)}", - ), - ) - try: # Start the run run = runtime.start_task_run(workspace, task_id) diff --git a/tests/integration/test_tasks_v2_engine.py b/tests/integration/test_tasks_v2_engine.py index 72ad9a09..01389702 100644 --- a/tests/integration/test_tasks_v2_engine.py +++ b/tests/integration/test_tasks_v2_engine.py @@ -233,13 +233,13 @@ def test_start_single_with_react_engine(self, tmp_path, client): assert resp.status_code == 200 # Give background thread a moment to invoke mock - time.sleep(0.5) + time.sleep(1.0) mock_exec.assert_called_once() _, kwargs = mock_exec.call_args assert kwargs["engine"] == "react" def test_start_single_invalid_engine(self, tmp_path, client): - """Invalid engine value in query param should return 400.""" + """Invalid engine value in query param should return 422 (Literal validation).""" ws = _make_workspace(tmp_path) task = _make_task(ws) @@ -251,9 +251,7 @@ def test_start_single_invalid_engine(self, tmp_path, client): }, ) - assert resp.status_code == 400 - data = resp.json() - assert "detail" in data + assert resp.status_code == 422 # --------------------------------------------------------------------------- @@ -311,3 +309,33 @@ def test_approve_invalid_engine(self, tmp_path, client): ) assert resp.status_code == 422 + + def test_approve_without_execution_skips_engine(self, tmp_path, client): + """Engine should be irrelevant when start_execution=False (no batch started).""" + ws = _make_workspace(tmp_path) + _make_task(ws, status=TaskStatus.BACKLOG) + + approval = ApprovalResult( + approved_count=1, + excluded_count=0, + approved_task_ids=["dummy"], + excluded_task_ids=[], + ) + + with ( + patch("codeframe.ui.routers.tasks_v2.runtime.approve_tasks", return_value=approval), + patch("codeframe.ui.routers.tasks_v2.conductor.start_batch") as mock_batch, + ): + resp = client.post( + "/api/v2/tasks/approve", + json={ + "start_execution": False, + "engine": "react", + }, + params={"workspace_path": str(ws.repo_path)}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["batch_id"] is None + mock_batch.assert_not_called()