Files
opensquilla--opensquilla/tests/test_cli/test_gateway_client_keepalive.py
2026-07-13 13:12:33 +08:00

231 lines
7.0 KiB
Python

from __future__ import annotations
import asyncio
import json
import sys
from types import SimpleNamespace
from typing import Any
import pytest
from opensquilla.cli.gateway_client import (
GatewayClient,
_normalize_session_error_payload,
_task_terminal_as_session_event,
)
_STOP = object()
class _FakeWebSocket:
def __init__(self, recv_frames: list[dict[str, Any]] | None = None) -> None:
self._recv_frames = list(recv_frames or [])
self.sent: list[str] = []
self.iter_queue: asyncio.Queue[str | object] = asyncio.Queue()
self.closed = False
async def recv(self) -> str:
if not self._recv_frames:
raise AssertionError("unexpected recv() call")
return json.dumps(self._recv_frames.pop(0))
async def send(self, payload: str) -> None:
self.sent.append(payload)
async def close(self) -> None:
self.closed = True
def __aiter__(self) -> _FakeWebSocket:
return self
async def __anext__(self) -> str:
item = await self.iter_queue.get()
if item is _STOP:
raise StopAsyncIteration
assert isinstance(item, str)
return item
class _BrokenSendWebSocket:
async def send(self, payload: str) -> None:
raise RuntimeError("socket already closed")
async def _wait_for(predicate, *, timeout: float = 1.0) -> None:
deadline = asyncio.get_running_loop().time() + timeout
while not predicate():
if asyncio.get_running_loop().time() >= deadline:
raise AssertionError("condition was not met before timeout")
await asyncio.sleep(0.005)
def _install_fake_websockets(monkeypatch: pytest.MonkeyPatch, ws: _FakeWebSocket) -> None:
async def _connect(url: str) -> _FakeWebSocket:
return ws
monkeypatch.setitem(sys.modules, "websockets", SimpleNamespace(connect=_connect))
def test_task_terminal_event_uses_terminal_message() -> None:
event = _task_terminal_as_session_event(
"task.timeout",
{
"task_id": "task-1",
"terminal_reason": "timeout",
"terminal_message": "The task timed out before it could finish.",
"error_message": "Gateway task timeout: Stream idle for more than 60s",
},
)
assert event is not None
assert event["event"] == "session.event.error"
assert event["message"] == "The task timed out before it could finish."
assert "Gateway task" not in event["message"]
def test_session_error_payload_is_normalized_for_gateway_client() -> None:
payload = _normalize_session_error_payload(
{
"message": "Iteration 1 exceeded iteration_timeout",
"code": "iteration_timeout",
}
)
assert payload["message"] == "The task timed out before it could finish."
assert payload["terminal_message"] == "The task timed out before it could finish."
assert payload["error_message"] == "The task timed out before it could finish."
def test_gateway_client_error_normalization_preserves_generic_error_detail() -> None:
payload = _normalize_session_error_payload(
{
"message": "Tool failed with exit code 2",
"code": "agent_error",
}
)
assert payload["message"] == "The task failed before it could finish."
assert payload["error_message"] == "Tool failed with exit code 2"
def _handshake_frames(*, keepalive_ms: int = 60_000) -> list[dict[str, Any]]:
return [
{"type": "event", "event": "connect.challenge", "payload": {"nonce": "n"}},
{
"type": "hello-ok",
"policy": {"client_ws_keepalive_timeout_ms": keepalive_ms},
},
]
@pytest.mark.asyncio
async def test_heartbeat_interval_derived_from_hello_policy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
ws = _FakeWebSocket(_handshake_frames(keepalive_ms=60_000))
_install_fake_websockets(monkeypatch, ws)
client = GatewayClient()
await client.connect()
try:
assert client._heartbeat_interval == 24.0 # noqa: SLF001
assert client._heartbeat_task is not None # noqa: SLF001
finally:
await client.close()
@pytest.mark.asyncio
async def test_heartbeat_interval_stays_below_short_hello_policy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
ws = _FakeWebSocket(_handshake_frames(keepalive_ms=2_000))
_install_fake_websockets(monkeypatch, ws)
client = GatewayClient()
await client.connect()
try:
assert client._heartbeat_interval == 0.8 # noqa: SLF001
finally:
await client.close()
@pytest.mark.asyncio
async def test_heartbeat_loop_sends_text_ping_not_rpc() -> None:
ws = _FakeWebSocket()
client = GatewayClient()
client._ws = ws # noqa: SLF001
client._heartbeat_interval = 0.01 # noqa: SLF001
task = asyncio.create_task(client._heartbeat_loop()) # noqa: SLF001
try:
await _wait_for(lambda: bool(ws.sent))
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
frame = json.loads(ws.sent[0])
assert frame == {"type": "ping"}
assert "id" not in frame
assert "method" not in frame
@pytest.mark.asyncio
async def test_listener_ignores_pong_frames() -> None:
ws = _FakeWebSocket()
client = GatewayClient()
client._ws = ws # noqa: SLF001
task = asyncio.create_task(client._listen()) # noqa: SLF001
try:
await ws.iter_queue.put(json.dumps({"type": "pong"}))
await asyncio.sleep(0.02)
assert client._recv_queue.empty() # noqa: SLF001
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
@pytest.mark.asyncio
async def test_close_cancels_heartbeat(monkeypatch: pytest.MonkeyPatch) -> None:
ws = _FakeWebSocket(_handshake_frames(keepalive_ms=60_000))
_install_fake_websockets(monkeypatch, ws)
client = GatewayClient()
await client.connect()
heartbeat_task = client._heartbeat_task # noqa: SLF001
assert heartbeat_task is not None
await client.close()
assert heartbeat_task.done()
assert ws.closed is True
@pytest.mark.asyncio
async def test_listener_close_stops_heartbeat_task() -> None:
ws = _FakeWebSocket()
client = GatewayClient()
client._ws = ws # noqa: SLF001
client._heartbeat_interval = 60.0 # noqa: SLF001
client._heartbeat_task = asyncio.create_task(client._heartbeat_loop(ws)) # noqa: SLF001
listener_task = asyncio.create_task(client._listen()) # noqa: SLF001
await ws.iter_queue.put(_STOP)
await _wait_for(lambda: client._connection_error is not None) # noqa: SLF001
assert client._heartbeat_task.done() # noqa: SLF001
await listener_task
@pytest.mark.asyncio
async def test_call_after_send_failure_raises_clear_connection_error() -> None:
client = GatewayClient()
client._ws = _BrokenSendWebSocket() # noqa: SLF001
with pytest.raises(ConnectionError, match="Gateway connection lost"):
await client._call("sessions.messages.subscribe", {"key": "agent:main:x"}) # noqa: SLF001
assert client._pending == {} # noqa: SLF001