diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 796cc53a4f..0d1ec17de8 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -3465,7 +3465,12 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter): if event_type == "reasoning.available": events.enqueue("tool.progress", {"message_id": message_id, "tool_name": tool_name or "_thinking", "delta": preview or ""}) elif event_type in {"tool.started", "tool.completed", "tool.failed"}: - events.enqueue(event_type, {"message_id": message_id, "tool_name": tool_name, "preview": preview, "args": args}) + event_name = ( + "tool.failed" + if event_type == "tool.completed" and kwargs.get("is_error") + else event_type + ) + events.enqueue(event_name, {"message_id": message_id, "tool_name": tool_name, "preview": preview, "args": args}) def _commentary(text: str, *, already_streamed: bool = False) -> None: # Mid-turn assistant commentary (Codex ``phase="commentary"``, text beside tool calls) diff --git a/tests/gateway/test_session_api.py b/tests/gateway/test_session_api.py index a3a2ae94c2..7a87cacf95 100644 --- a/tests/gateway/test_session_api.py +++ b/tests/gateway/test_session_api.py @@ -376,6 +376,38 @@ async def test_session_chat_stream_disconnect_keeps_control_refs_until_executor_ assert run_id not in adapter._active_run_agents +@pytest.mark.asyncio +async def test_session_chat_stream_classifies_failed_tool_completions(adapter, session_db): + session_id = session_db.create_session("tool-status-stream", "api_server") + + async def fake_run(**kwargs): + progress = kwargs["tool_progress_callback"] + progress("tool.completed", tool_name="read_file", is_error=False) + progress("tool.completed", tool_name="terminal", is_error=True) + progress("tool.failed", tool_name="web_search") + return {"final_response": "done", "session_id": session_id}, {"total_tokens": 1} + + app = _create_session_app(adapter) + with patch.object(adapter, "_run_agent", side_effect=fake_run): + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + f"/api/sessions/{session_id}/chat/stream", + json={"message": "run tools"}, + ) + assert resp.status == 200 + body = await resp.text() + + blocks = body.split("\n\n") + assert any("event: tool.completed" in b and '"tool_name": "read_file"' in b for b in blocks) + assert any("event: tool.failed" in b and '"tool_name": "terminal"' in b for b in blocks) + assert any("event: tool.failed" in b and '"tool_name": "web_search"' in b for b in blocks) + assert body.count("event: tool.completed") == 1 + assert body.count("event: tool.failed") == 2 + assert '"tool_name": "read_file"' in body + assert '"tool_name": "terminal"' in body + assert '"tool_name": "web_search"' in body + + @pytest.mark.asyncio async def test_session_chat_stream_run_completed_carries_turn_transcript(adapter, session_db): """run.completed must include the full interleaved turn transcript so a diff --git a/website/docs/user-guide/features/api-server.md b/website/docs/user-guide/features/api-server.md index 142caf45b0..1c940e6db4 100644 --- a/website/docs/user-guide/features/api-server.md +++ b/website/docs/user-guide/features/api-server.md @@ -614,7 +614,7 @@ External UIs can manage Hermes sessions over REST without standing up the dashbo | `GET` | `/api/sessions/{id}/messages` | Message history for a session | | `POST` | `/api/sessions/{id}/fork` | Branch the session via `SessionDB` lineage (matches CLI `/branch` semantics) | | `POST` | `/api/sessions/{id}/chat` | Run one synchronous agent turn | -| `POST` | `/api/sessions/{id}/chat/stream` | SSE wrapper over a single turn — emits `assistant.delta`, `assistant.commentary` (mid-turn commentary: `message_id`, `text`, `already_streamed`; never folded into `assistant.completed`), `tool.started`, `tool.completed`, then a terminal `run.completed` / `run.failed` / `run.cancelled` event that matches how the turn ended (see [Terminal run status](../../developer-guide/programmatic-integration.md#terminal-run-status)) | +| `POST` | `/api/sessions/{id}/chat/stream` | SSE wrapper over a single turn — emits `assistant.delta`, `assistant.commentary` (mid-turn commentary: `message_id`, `text`, `already_streamed`; never folded into `assistant.completed`), `tool.started`, `tool.completed`, `tool.failed` (a tool that finished with an error), then a terminal `run.completed` / `run.failed` / `run.cancelled` event that matches how the turn ended (see [Terminal run status](../../developer-guide/programmatic-integration.md#terminal-run-status)) | `/v1/capabilities` advertises the full surface via `session_*` feature flags and `endpoints.session_*` entries so external UIs can detect support and fall back safely. Inline images are supported in `chat` and `chat/stream` payloads (multimodal-aware path).