diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index a1b066a85b4a..0c22ae3b9230 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -5975,7 +5975,43 @@ class APIServerAdapter(BasePlatformAdapter): "timestamp": ts, "text": preview or "", }) - # _thinking and subagent_progress are intentionally not forwarded + elif event_type in {"subagent.start", "subagent.complete"}: + event = { + "event": event_type, + "run_id": run_id, + "timestamp": ts, + } + if preview is not None: + event["preview"] = preview + for key in ( + "goal", + "task_count", + "task_index", + "subagent_id", + "parent_id", + "depth", + "model", + "tool_count", + "status", + "summary", + "duration_seconds", + "input_tokens", + "output_tokens", + "reasoning_tokens", + "api_calls", + "cost_usd", + "files_read", + "files_written", + "output_tail", + ): + value = kwargs.get(key) + if value is not None: + event[key] = value + _push(event) + # _thinking, subagent.tool, and subagent_progress are intentionally + # not forwarded on the /v1/runs stream: they are high-volume UI + # noise. Lifecycle boundaries (start/complete) still need to land + # so clients can observe delegate_task timeouts and failures. return _callback diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 347dc423a86d..37eb3f35f343 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -891,6 +891,57 @@ class TestAgentExecution: assert captured["service_tier"] == "priority" +class TestRunEventCallback: + @pytest.mark.asyncio + async def test_forwards_subagent_lifecycle_events(self, adapter): + run_id = "run_subagent_events" + loop = asyncio.get_running_loop() + queue = asyncio.Queue() + adapter._run_streams[run_id] = queue + adapter._run_statuses.pop(run_id, None) + + callback = adapter._make_run_event_callback(run_id, loop) + + callback( + "subagent.start", + preview="research candidate issue", + goal="research candidate issue", + task_index=0, + task_count=1, + subagent_id="deleg_123", + ) + callback( + "subagent.complete", + preview="Timed out after 300s", + goal="research candidate issue", + task_index=0, + task_count=1, + subagent_id="deleg_123", + status="timeout", + duration_seconds=300.0, + summary="Subagent timed out after 300s with 2 API call(s) completed.", + api_calls=2, + ) + + start_event = await asyncio.wait_for(queue.get(), timeout=1.0) + complete_event = await asyncio.wait_for(queue.get(), timeout=1.0) + + assert start_event["event"] == "subagent.start" + assert start_event["preview"] == "research candidate issue" + assert start_event["task_index"] == 0 + assert start_event["task_count"] == 1 + assert start_event["subagent_id"] == "deleg_123" + + assert complete_event["event"] == "subagent.complete" + assert complete_event["preview"] == "Timed out after 300s" + assert complete_event["status"] == "timeout" + assert complete_event["duration_seconds"] == 300.0 + assert complete_event["api_calls"] == 2 + assert "timed out after 300s" in complete_event["summary"] + + assert adapter._run_statuses[run_id]["last_event"] == "subagent.complete" + + # --------------------------------------------------------------------------- # /health endpoint # ---------------------------------------------------------------------------