Files
hermes-agent/tests/gateway/test_api_server_reasoning_stream.py
teknium1 62dbead384 fix(api-server): pin the reasoning_callback wiring through _spawn_stream_agent/_run_agent/_create_agent
Replace the direct-queue chat-completions test with one that drives
_spawn_stream_agent with the real _run_agent/_create_agent path and a fake
AIAgent invoking reasoning_callback, asserting the delta.reasoning_content
frame reaches the SSE writer. Unwiring either the _spawn_stream_agent kwarg
or the AIAgent kwarg now fails the test.
2026-09-19 09:48:35 -07:00

143 lines
6.7 KiB
Python

"""Reasoning on the OpenAI-compatible SSE writers (#99552).
The agent's structured ``reasoning_callback`` reaches ``/v1/chat/completions`` as
``delta.reasoning_content`` and ``/v1/responses`` as the reasoning-summary event family,
kept distinct from answer text with monotonic ``sequence_number``.
"""
import asyncio
import json
import time
import uuid
from unittest.mock import MagicMock, patch
import pytest
from gateway.config import PlatformConfig
from gateway.platforms.api_server import APIServerAdapter, ThreadSafeAsyncQueue
def _frames(payloads):
out = []
for raw in payloads:
text = raw.decode() if isinstance(raw, bytes) else raw
event = None
for line in text.splitlines():
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and line != "data: [DONE]":
out.append((event, json.loads(line[6:])))
return out
def _fake_writer_env():
request = MagicMock()
request.headers = {}
written: list = []
class _FakeStreamResponse:
async def prepare(self, req):
pass
async def write(self, payload):
written.append(payload)
return request, written, _FakeStreamResponse()
@pytest.fixture
def adapter():
return APIServerAdapter(PlatformConfig(enabled=True, extra={}))
def _stub_create_agent_runtime(monkeypatch, fake_agent_cls):
"""Stub every external dependency of ``_create_agent`` so the real
``_spawn_stream_agent -> _run_agent -> _create_agent -> AIAgent(...)`` wiring runs."""
monkeypatch.setattr("run_agent.AIAgent", fake_agent_cls)
monkeypatch.setattr("gateway.run._resolve_runtime_agent_kwargs", lambda: {
"provider": "openrouter", "api_key": "sk-test", "base_url": "https://openrouter.ai/api/v1",
"api_mode": "chat_completions"})
monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "global/model")
monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {})
monkeypatch.setattr("gateway.run.GatewayRunner._load_reasoning_config", staticmethod(lambda model="": {}))
monkeypatch.setattr("gateway.run.GatewayRunner._load_fallback_model", staticmethod(lambda: None))
monkeypatch.setattr("gateway.run._current_max_iterations", lambda: 90)
monkeypatch.setattr("hermes_cli.tools_config._get_platform_tools", lambda *_: set())
@pytest.mark.asyncio
async def test_chat_completions_stream_forwards_agent_reasoning_callback(adapter, monkeypatch):
"""Production entry point: ``_spawn_stream_agent`` wires the agent's ``reasoning_callback``
through ``_run_agent`` / ``_create_agent`` into ``AIAgent(...)``; what the agent emits there
reaches the SSE writer as ``delta.reasoning_content`` while answer text stays in ``delta.content``."""
import gateway.platforms.api_server as api_mod
class FakeAgent:
def __init__(self, **kwargs):
self._reasoning_callback = kwargs.get("reasoning_callback")
self._stream_delta_callback = kwargs.get("stream_delta_callback")
self.session_id = kwargs.get("session_id")
def run_conversation(self, **kwargs):
if self._reasoning_callback is not None:
self._reasoning_callback("thinking...")
self._stream_delta_callback("answer")
return {"final_response": "answer", "completed": True}
_stub_create_agent_runtime(monkeypatch, FakeAgent)
monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None)
request, written, fake_response = _fake_writer_env()
stream_q = ThreadSafeAsyncQueue()
agent_task, agent_ref = adapter._spawn_stream_agent(
stream_q, user_message="q", conversation_history=[], session_id="api-session")
with patch.object(api_mod.web, "StreamResponse", return_value=fake_response):
await adapter._write_sse_chat_completion(
request, "chatcmpl-x", "hermes-agent", int(time.time()), stream_q, agent_task, agent_ref)
assert isinstance(agent_ref[0], FakeAgent)
deltas = [d["choices"][0]["delta"] for _e, d in _frames(written)]
assert [d.get("reasoning_content") for d in deltas if d.get("reasoning_content")] == ["thinking..."]
assert "".join(d.get("content") or "" for d in deltas) == "answer"
assert not any("thinking" in (d.get("content") or "") for d in deltas)
@pytest.mark.asyncio
async def test_responses_stream_emits_reasoning_summary_events_before_message(adapter):
"""A thinking burst becomes one ``reasoning`` output item (summary_part/text added→delta→done)
closed before the message item opens; it is echoed in ``response.completed`` and every
event's ``sequence_number`` stays strictly increasing."""
import gateway.platforms.api_server as api_mod
request, written, fake_response = _fake_writer_env()
stream_q = ThreadSafeAsyncQueue()
async def _agent():
stream_q.put_nowait(("__reasoning__", "step one "))
stream_q.put_nowait(("__reasoning__", "step two"))
stream_q.put_nowait("final text")
return {"final_response": "final text", "completed": True}, None
agent_task = asyncio.ensure_future(_agent())
agent_task.add_done_callback(lambda _f: stream_q.put_nowait(None))
with patch.object(api_mod.web, "StreamResponse", return_value=fake_response):
await adapter._write_sse_responses(
request=request, response_id=f"resp_{uuid.uuid4().hex[:28]}", model="hermes-agent",
created_at=int(time.time()), stream_q=stream_q, agent_task=agent_task, agent_ref=[None],
conversation_history=[], user_message="q", instructions=None, conversation=None,
store=False, session_id=None)
frames = _frames(written)
events = [e for e, _d in frames]
assert events[:8] == [
"response.created", "response.output_item.added", "response.reasoning_summary_part.added",
"response.reasoning_summary_text.delta", "response.reasoning_summary_text.delta",
"response.reasoning_summary_text.done", "response.reasoning_summary_part.done",
"response.output_item.done"]
assert events.index("response.output_item.done") < events.index("response.output_text.delta")
reasoning_done = next(d for e, d in frames if e == "response.reasoning_summary_text.done")
assert reasoning_done["text"] == "step one step two"
completed = next(d for e, d in frames if e == "response.completed")
assert [o["type"] for o in completed["response"]["output"]] == ["reasoning", "message"]
assert completed["response"]["output"][0]["summary"] == [
{"type": "summary_text", "text": "step one step two"}]
assert "step one" not in completed["response"]["output"][1]["content"][0]["text"]
seqs = [d["sequence_number"] for _e, d in frames]
assert seqs == list(range(len(seqs)))