Files
hermes-agent/tests/gateway/test_api_server_sse_keepalive.py
teknium1 8e00832d9b fix(api-server): /v1/runs events stream shares the SSE keepalive cadence; trim tests
The durable-run events stream (`GET /v1/runs/{id}/events`) hardcoded its own 30 s idle
keepalive, so it still fell outside the ~20 s idle deadline common to remote
OpenAI-compatible clients even after CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS dropped to
10 s. Point it at the shared constant so every API-server SSE writer (chat completions,
Responses, native session stream, run events) keeps the wire alive on the same cadence.

Rewrite the salvaged regression test as two invariants: an idle OpenAI stream writes a
`: keepalive` comment inside the remote-client window (module-local fake clock, no global
`time.monotonic`/`asyncio.wait_for` patching, which would also steer asyncio), and the run
events stream follows the shared constant end-to-end through aiohttp. Both are red on
origin/main.
2026-09-15 18:18:30 -07:00

92 lines
3.3 KiB
Python

"""API-server SSE keepalive cadence while the agent is silent (preflight compression, long
tool calls). Remote OpenAI-compatible clients commonly drop an idle stream after ~20 s and the
disconnect interrupts the turn, so every SSE writer must emit a comment frame before that
window closes (issue #111512)."""
import asyncio
import types
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from gateway.config import PlatformConfig
from gateway.platforms import api_server
from gateway.platforms import api_server_openai_routes as routes
from gateway.platforms.api_server import APIServerAdapter
from gateway.platforms.api_server_openai_routes import _iter_stream_items
REMOTE_CLIENT_IDLE_DEADLINE_S = 20.0
class _RecordingResponse:
def __init__(self) -> None:
self.writes: list[bytes] = []
async def write(self, data: bytes) -> None:
self.writes.append(data)
@pytest.fixture
def adapter():
return APIServerAdapter(PlatformConfig(enabled=True, extra={}))
@pytest.mark.asyncio
async def test_idle_openai_stream_writes_keepalive_before_remote_client_deadline(monkeypatch):
"""Chat/Responses streams (``_iter_stream_items``) keep the wire alive inside the client window."""
assert api_server.CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS <= REMOTE_CLIENT_IDLE_DEADLINE_S
# Module-local fake clock: each read is one keepalive interval later, so the poll loop
# observes an idle stream without waiting real seconds (asyncio keeps the real clock).
reads = [0.0]
step = api_server.CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS
def monotonic():
reads[0] += step
return reads[0]
monkeypatch.setattr(routes, "time", types.SimpleNamespace(monotonic=monotonic))
stream_q: asyncio.Queue = asyncio.Queue()
agent_task = asyncio.get_running_loop().create_future()
response = _RecordingResponse()
async def _finish_after_first_keepalive():
while not response.writes:
await asyncio.sleep(0.05)
agent_task.set_result(None)
finisher = asyncio.create_task(_finish_after_first_keepalive())
items = [item async for item in _iter_stream_items(stream_q, agent_task, response)]
await finisher
assert items == []
assert response.writes[0] == b": keepalive\n\n"
@pytest.mark.asyncio
async def test_idle_run_events_stream_uses_shared_keepalive_cadence(monkeypatch, adapter):
"""``GET /v1/runs/{id}/events`` follows the same keepalive constant as the OpenAI routes."""
monkeypatch.setattr(api_server, "CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS", 0.2)
queue: asyncio.Queue = asyncio.Queue()
adapter._run_streams["run_idle"] = queue
adapter._set_run_status("run_idle", "running")
monkeypatch.setattr(adapter, "_request_owns_run", lambda request, run_id: True)
app = web.Application()
app["api_server_adapter"] = adapter
app.router.add_get("/v1/runs/{run_id}/events", adapter._handle_run_events)
async def _close_after_idle():
await asyncio.sleep(0.5)
await queue.put(None)
async with TestClient(TestServer(app)) as cli:
closer = asyncio.create_task(_close_after_idle())
resp = await cli.get("/v1/runs/run_idle/events")
body = await resp.text()
await closer
assert resp.status == 200
assert body.count(": keepalive\n\n") >= 1