diff --git a/tests/ui/test_v2_routers_integration.py b/tests/ui/test_v2_routers_integration.py index 57458c80..28c25e3a 100644 --- a/tests/ui/test_v2_routers_integration.py +++ b/tests/ui/test_v2_routers_integration.py @@ -709,6 +709,73 @@ def test_start_task_not_found(self, test_client): assert response.status_code == 404 + def test_start_task_execute_runs_agent_to_terminal( + self, test_client_with_task, monkeypatch + ): + """Start with execute=true dispatches the agent (BUILD path) to a terminal run. + + Covers the agent-dispatch branch (`_run_agent` -> `runtime.execute_agent`), + which the other `/start` tests skip by omitting `execute=true` (#749). + + MockProvider is injected via CODEFRAME_LLM_PROVIDER=mock: the router builds + it through `get_provider("mock")`, and its text-only response makes the + ReAct agent complete on the first iteration (react_agent.py:518). + """ + import threading + import time + + from codeframe.core import runtime, streaming + + monkeypatch.setenv("CODEFRAME_LLM_PROVIDER", "mock") + + task_id = test_client_with_task.task.id + workspace = test_client_with_task.workspace + + # Snapshot threads so we can join the endpoint's fire-and-forget worker + # before the fixture deletes the temp workspace (avoids a teardown race + # where the still-draining thread touches a removed DB). + threads_before = set(threading.enumerate()) + + response = test_client_with_task.post( + f"/api/v2/tasks/{task_id}/start?execute=true" + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["status"] == "executing" + run_id = data["run_id"] + + # execute_agent runs in a background daemon thread; poll the run until it + # leaves RUNNING (terminal = COMPLETED / FAILED / BLOCKED). + deadline = time.monotonic() + 30.0 + status = "RUNNING" + while time.monotonic() < deadline: + run_resp = test_client_with_task.get(f"/api/v2/tasks/{task_id}/run") + assert run_resp.status_code == 200 + status = run_resp.json()["status"] + if status != runtime.RunStatus.RUNNING.value: + break + time.sleep(0.2) + + # Let the worker thread finish its tail work (logging/recording) before + # assertions and fixture teardown remove the workspace out from under it. + for thread in set(threading.enumerate()) - threads_before: + thread.join(timeout=10.0) + + assert status != runtime.RunStatus.RUNNING.value, ( + f"Run {run_id} never reached a terminal status (stuck at {status})" + ) + assert status == runtime.RunStatus.COMPLETED.value, ( + f"Expected COMPLETED for the mock-driven BUILD path, got {status}" + ) + + # The BUILD path emitted output events (the whole point of dispatching). + # Assert on file *content*, not mere existence: RunOutputLogger creates the + # output file at execute_agent start, so path.exists() only proves the run + # started — a non-empty file proves the agent actually wrote output. + output_path = streaming.get_run_output_path(workspace, run_id) + assert output_path.stat().st_size > 0, "BUILD path produced no output" + def test_get_task_run(self, test_client_with_task): """Get task run status after starting.""" task_id = test_client_with_task.task.id