Nothing open on GitHub. Fix merged in #24578 on 12 May 2026. Check it covers your case.
The short answer
0open issues
0open PRs
4items grouped
- Known?
- Yes. 4 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #24578. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #24578. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and pull requests address a race condition where SSE streams fail to receive a completion signal when an agent task finishes. Multiple fix approaches involve sending a sentinel or waking the SSE writer upon task completion.
Open issues0
Open PRs0
Fixes on record
-
#24578 fix(gateway): enqueue SSE EOS sentinel on task completion
files:
gateway/platforms/api_server.py,tests/gateway/test_api_server.pyShow the diff
-
gateway/platforms/api_server.py@@ -1168,6 +1168,9 @@ def _on_tool_complete(tool_call_id, function_name, function_args, function_resul agent_ref=agent_ref, gateway_session_key=gateway_session_key, )) + # Ensure SSE drain loops can terminate without relying on polling + # agent_task.done(), which can race with queue timeout checks. + agent_task.add_done_callback(lambda _fut: _stream_q.put(None)) return await self._write_sse_chat_completion( request, completion_id, model_name, created, _stream_q, @@ -2197,6 +2200,9 @@ def _on_tool_complete(tool_call_id, function_name, function_args, function_resul agent_ref=agent_ref, gateway_session_key=gateway_session_key, )) + # Ensure SSE drain loops can terminate without relying on polling + # agent_task.done(), which can race with queue timeout checks. + agent_task.add_done_callback(lambda _fut: _stream_q.put(None)) response_id = f"resp_{uuid.uuid4().hex[:28]}" model_name = body.get("model", self._model_name) -
tests/gateway/test_api_server.py@@ -681,6 +681,56 @@ async def _mock_run_agent(**kwargs): assert "[DONE]" in body assert "Hello!" in body + @pytest.mark.asyncio + async def test_stream_task_done_callback_enqueues_eos_for_chat_completions(self, adapter): + """Regression guard for #24451: completion callback must signal SSE EOS.""" + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + class _FakeTask: + def __init__(self): + self.callbacks = [] + + def add_done_callback(self, cb): + self.callbacks.append(cb) + + fake_task = _FakeTask() + + def _fake_ensure_future(coro): + # We short-circuit task scheduling in this unit test. + coro.close() + return fake_task + + with ( + patch.object( + adapter, + "_run_agent", + new=AsyncMock( + return_value=( + {"final_response": "ok", "messages": [], "api_calls": 1}, + {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + ) + ), + ), + patch("gateway.platforms.api_server.asyncio.ensure_future", side_effect=_fake_ensure_future), + patch.object(adapter, "_write_sse_chat_completion", new_callable=AsyncMock) as mock_write_sse, + ): + mock_write_sse.return_value = web.Response(status=200, text="ok") + resp = await cli.post( + "/v1/chat/completions", + json={ + "model": "test", + "messages": [{"role": "user", "content": "hi"}], + "stream": True, + }, + ) + assert resp.status == 200 + + assert len(fake_task.callbacks) == 1 + stream_q = mock_write_sse.call_args.args[4] + assert stream_q.empty() + fake_task.callbacks[0](fake_task) + assert stream_q.get_nowait() is None + @pytest.mark.asyncio async def test_stream_sends_keepalive_during_quiet_tool_gap(self, adapter): """Idle SSE streams should send keepalive comments while tools run silently.""" @@ -1676,6 +1726,52 @@ async def _mock_run_agent(**kwargs): assert "Hello" in body assert " world" in body + @pytest.mark.asyncio + async def test_stream_task_done_callback_enqueues_eos_for_responses(self, adapter): + """Regression guard for #24451 on /v1/responses streaming path.""" + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + class _FakeTask: + def __init__(self): + self.callbacks = [] + + def add_done_callback(self, cb): + self.callbacks.append(cb) + + fake_task = _FakeTask() + + def _fake_ensure_future(coro): + # We short-circuit task scheduling in this unit test. + coro.close() + return fake_task + + with ( + patch.object( + adapter, + "_run_agent", + new=AsyncMock( + return_value=( + {"final_response": "ok", "messages": [], "api_calls": 1}, + {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + ) + ), + ), + patch("gateway.platforms.api_server.asyncio.ensure_future", side_effect=_fake_ensure_future), + patch.object(adapter, "_write_sse_responses", new_callable=AsyncMock) as mock_write_sse, + ): + mock_write_sse.return_value = web.Response(status=200, text="ok") + resp = await cli.post( + "/v1/responses", + json={"model": "hermes-agent", "input": "hi", "stream": True}, + ) + assert resp.status == 200 + + assert len(fake_task.callbacks) == 1 + stream_q = mock_write_sse.call_args.kwargs["stream_q"] + assert stream_q.empty() + fake_task.callbacks[0](fake_task) + assert stream_q.get_nowait() is None + @pytest.mark.asyncio async def test_stream_emits_function_call_and_output_items(self, adapter): app = _create_app(adapter) @@ -3061,4 +3157,3 @@ async def test_capabilities_advertises_session_key_header(self, adapter): assert resp.status == 200 data = await resp.json() assert data["features"]["session_key_header"] == "X-Hermes-Session-Key" -
-
2 PRs closed without merging
From the close comments: superseded by another PR 2
Closed issues · 1
- Closed#24451SSE streaming: response.completed never sent due to race condition with agent_task.done()
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Merged PRs
- #24578
- Closed issues
- 1
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 12 May 2026