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.
143 lines
6.7 KiB
Python
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)))
|