diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py index d1f735a9..11b0834a 100644 --- a/backend/apps/agents/schedule_mcp_server.py +++ b/backend/apps/agents/schedule_mcp_server.py @@ -12,6 +12,7 @@ ScheduleWorkflow for exact, user-specified live schedules. import json import sys import os +import time import uuid import urllib.request import urllib.error @@ -212,13 +213,14 @@ TOOLS = [ { "name": "TestWorkflow", "description": ( - "Spawn a sibling Test Agent that runs the workflow end-to-end " - "(the current draft if one is being edited, else the live steps) " - "so the user can watch it work. Use after editing a step to " - "verify the change. The Test Agent renders as a sibling card on " - "the dashboard with a 'Testing' arrow chip linking back to this " - "workflow. After it finishes, call ReadTestTranscript to see what " - "it did." + "Run the workflow end-to-end (the current draft if one is being " + "edited, else the live steps) and WAIT for the result, which is " + "returned to you in this same turn. Use after editing a step to " + "verify the change, then keep going: fix what the transcript " + "shows and test again, without stopping to ask the user. The Test " + "Agent renders as a sibling card on the dashboard with a 'Testing' " + "arrow chip so they can watch. Only if it runs unusually long does " + "this return early, telling you to call ReadTestTranscript." ), "inputSchema": { "type": "object", @@ -529,6 +531,12 @@ def handle_delete_step(args: dict) -> dict: return _ok(f"Step {idx + 1} deleted ({len(steps)} remaining).") +# How long a synchronous test may hold the turn. Long enough for a real multi-step workflow, short +# enough that a wedged test returns an honest "still running" instead of hanging the conversation. +P_TEST_WAIT_S = 240 +P_TEST_POLL_S = 3 + + def handle_test_workflow(args: dict) -> dict: wid = args.get("workflow_id") or "" if not wid: @@ -537,7 +545,26 @@ def handle_test_workflow(args: dict) -> dict: if "_error" in r: return _err(r["_error"]) sid = r.get("session_id", "") - return _ok(f"Test Agent spawned (session {sid[:8]}...). It runs the latest workflow on the dashboard with a Testing arrow chip. Call ReadTestTranscript once it finishes to see what it did.") + # Blocking on purpose. This used to return the moment the Test Agent spawned and tell the model + # to "call ReadTestTranscript once it finishes", but a model has no way to know when that is, so + # it ended its turn and the HUMAN had to keep re-pinging it. A test whose result the caller + # cannot observe is not a tool, it is homework for the user. + deadline = time.time() + P_TEST_WAIT_S + last_status = "running" + while time.time() < deadline: + time.sleep(P_TEST_POLL_S) + t = _call("GET", f"/{wid}/test-transcript") + if "_error" in t: + continue + last_status = t.get("status") or "running" + if last_status in ("running", "none"): + continue + transcript = t.get("transcript") or "(empty transcript)" + return _ok(f"Test finished (status: {last_status}). Transcript:\n\n{transcript}") + return _ok( + f"Test Agent (session {sid[:8]}) is still running after {P_TEST_WAIT_S}s, so it is a long one. " + f"Last status: {last_status}. Call ReadTestTranscript to pick up the result." + ) def handle_read_test_transcript(args: dict) -> dict: diff --git a/backend/tests/test_sync_workflow_test.py b/backend/tests/test_sync_workflow_test.py new file mode 100644 index 00000000..2d3160bf --- /dev/null +++ b/backend/tests/test_sync_workflow_test.py @@ -0,0 +1,27 @@ +"""TestWorkflow must return the RESULT, not a promise. The old handler returned as soon as the Test +Agent spawned, so the model ended its turn and a human had to re-ping it to continue: the exact +"it just stops" annoyance.""" + +import inspect + +from backend.apps.agents import schedule_mcp_server as srv + + +def test_the_handler_waits_for_the_result_instead_of_returning_a_promise(): + src = inspect.getsource(srv.handle_test_workflow) + assert "while time.time() < deadline" in src, "the tool must block until the test settles" + assert "test-transcript" in src, "and read the transcript itself, not delegate that to the model" + assert "Test finished" in src + + +def test_a_long_test_returns_honestly_instead_of_hanging_the_turn(): + src = inspect.getsource(srv.handle_test_workflow) + assert "still running after" in src + assert srv.P_TEST_WAIT_S <= 300, "a bounded wait; a wedged test must never hold a turn forever" + assert srv.P_TEST_POLL_S >= 1 + + +def test_the_description_tells_the_model_to_keep_going(): + desc = next(t["description"] for t in srv.TOOLS if t["name"] == "TestWorkflow") + assert "WAIT for the result" in desc and "same turn" in desc + assert "without stopping to ask the user" in desc